diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index c6bd417..d122ce7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -23,7 +23,7 @@ jobs: - name: Setup PHP with composer v2 uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_EXTENSIONS }} + php-version: ${{ env.PHP_VERSION }} tools: composer:v2 - name: Validate composer.json and composer.lock @@ -45,7 +45,7 @@ jobs: - name: Setup PHP with composer v2 uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_EXTENSIONS }} + php-version: ${{ env.PHP_VERSION }} tools: composer:v2 - name: Install dependencies @@ -64,7 +64,7 @@ jobs: - name: Setup PHP with composer v2 uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_EXTENSIONS }} + php-version: ${{ env.PHP_VERSION }} tools: composer:v2 - name: Install dependencies diff --git a/.gitignore b/.gitignore index 106a68b..675fea7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ vendor/ coverage/ .phpunit.result.cache + +# Generated class-loader cache (a build artefact, not source) +class-loader-cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9040d..8455eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/) and will adhere to [Semantic Versioning](http://semver.org/). ## [Unreleased] - TBD +### Added +- Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)). +- Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md). +- The loader debug page reports per-loader timing: how long class **discovery** took (a cache read when cached, a live filesystem scan otherwise) and how long **class lookup** (reflection, instantiation and registration) took. The staleness check also reports how long its live discovery ran, so the cache's saving on a given site is measurable. + +### Changed +- **Breaking: the minimum required PHP version is now 8.3** (raised from 8.2). Full native type coverage relies on [typed class constants](https://www.php.net/manual/en/language.oop5.constants.php), which are a PHP 8.3 feature and are a parse error on 8.2. This reverses the lowering to 8.2 made in 1.2.0 ([#8](https://github.com/10up/wp-framework/pull/8)). See the [Upgrade Guide](docs/Upgrade-Guide.md). +- **Breaking (static analysis only): `AbstractPostType::get_name()` and `AbstractTaxonomy::get_name()` now document a `lowercase-string&non-empty-string` return type**, stating what WordPress already requires of a post type or taxonomy key. The native `: string` signature is unchanged, so no subclass breaks and no runtime behaviour differs, but a project running PHPStan may need to narrow how it builds a dynamic key. See the [Upgrade Guide](docs/Upgrade-Guide.md). +- Full native type coverage across `src/`: every return type, parameter, property and class constant now declares a native type, and the generic types of every `array` are documented. Enforced by `tomasvotruba/type-coverage` through `composer run static`, which also no longer ignores `missingType.iterableValue`. +- The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)). +- A corrupt or truncated shipped cache is caught at runtime and the request falls back to a live scan instead of fataling, so a bad cache degrades performance rather than taking the site down. The fallback fires a `tenup_framework_cache_load_failed` action (for logging or alerting) and the loader debug page flags that loader red as "Cache failed to load — running live" instead of reporting it as in use. +- Bumped the cache filename so a cache written by an older version is ignored after upgrade rather than served stale. +- `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache). + +### Removed +- Automatic runtime cache generation and its environment gating — `should_use_cache()`, the `production`/`staging` checks, and the `VIP_GO_APP_ENVIRONMENT` handling. Caching is now produced at build time instead. ## [1.2.0] - 2025-03-20 ### Changed diff --git a/README.md b/README.md index 2d5b789..8bdced8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - **Shared Functionality:** Provides commonly used abstract classes and utilities to reduce boilerplate code in WordPress projects. - **Extendability:** Built for easy extension. Engineers can subclass or override functionality as needed to tailor it to their projects. - **Centralized Updates:** Simplifies rolling out updates and new features across projects using this framework. -- **Modern Standards:** Compatible with PHP 8.2+ and adheres to modern development practices. +- **Modern Standards:** Compatible with PHP 8.3+ and adheres to modern development practices. ## Installation diff --git a/bin/tenup-framework-generate-class-cache b/bin/tenup-framework-generate-class-cache new file mode 100755 index 0000000..23458fb --- /dev/null +++ b/bin/tenup-framework-generate-class-cache @@ -0,0 +1,76 @@ +#!/usr/bin/env php + [ ...] + * + * Pass the same directory (or directories) you pass to + * TenupFramework\ModuleInitialization::init_classes() — usually your plugin/theme `inc/`. + * + * @package TenupFramework + */ + +declare( strict_types = 1 ); + +namespace TenupFramework\Bin; + +use TenupFramework\ModuleInitialization; +use Throwable; + +// Locate the Composer autoloader whether this runs from within the package itself +// (development) or installed inside a consumer project's vendor directory. Composer 2.2+ +// exposes the path via this global from the generated bin proxy; otherwise fall back to +// the known relative locations. +$tenup_autoloader_loaded = false; + +if ( isset( $GLOBALS['_composer_autoload_path'] ) && file_exists( (string) $GLOBALS['_composer_autoload_path'] ) ) { + require $GLOBALS['_composer_autoload_path']; + $tenup_autoloader_loaded = true; +} else { + $tenup_autoload_candidates = [ + __DIR__ . '/../vendor/autoload.php', // Running from the package itself. + __DIR__ . '/../../../autoload.php', // Installed at vendor/10up/wp-framework/bin. + ]; + + foreach ( $tenup_autoload_candidates as $tenup_autoload_candidate ) { + if ( file_exists( $tenup_autoload_candidate ) ) { + require $tenup_autoload_candidate; + $tenup_autoloader_loaded = true; + break; + } + } +} + +if ( ! $tenup_autoloader_loaded ) { + fwrite( STDERR, "Could not locate the Composer autoloader. Run `composer install` first.\n" ); + exit( 1 ); +} + +// Target directories are the CLI arguments (everything after the script name). +$tenup_directories = array_slice( $argv, 1 ); + +if ( empty( $tenup_directories ) ) { + fwrite( STDERR, "Usage: tenup-framework-generate-class-cache [ ...]\n" ); + fwrite( STDERR, "Pass one or more directories (the same ones passed to ModuleInitialization::init_classes()).\n" ); + exit( 1 ); +} + +$tenup_exit_code = 0; + +foreach ( $tenup_directories as $tenup_directory ) { + try { + $tenup_classes = ModuleInitialization::instance()->generate_cache( $tenup_directory ); + fwrite( STDOUT, sprintf( "Cached %d class(es) for %s\n", count( $tenup_classes ), $tenup_directory ) ); + } catch ( Throwable $tenup_exception ) { + fwrite( STDERR, sprintf( "Failed to generate cache for %s: %s\n", $tenup_directory, $tenup_exception->getMessage() ) ); + $tenup_exit_code = 1; + } +} + +exit( $tenup_exit_code ); diff --git a/composer.json b/composer.json index a97cfc4..61052d3 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,9 @@ "role": "Developer" } ], + "bin": [ + "bin/tenup-framework-generate-class-cache" + ], "autoload": { "psr-4": { "TenupFramework\\": "src/" @@ -30,7 +33,7 @@ } }, "require": { - "php": ">=8.2", + "php": ">=8.3", "spatie/php-structure-discoverer": "^2.2" }, "require-dev": { @@ -41,9 +44,10 @@ "php-stubs/wp-cli-stubs": "^2.11", "phpstan/phpstan-deprecation-rules": "^2.0", "10up/phpcs-composer": "^3.0", - "phpcompatibility/php-compatibility": "dev-develop as 9.99.99", "phpunit/php-code-coverage": "^9.2", - "slevomat/coding-standard": "^8.15" + "slevomat/coding-standard": "^8.15", + "rector/rector": "^2.0", + "tomasvotruba/type-coverage": "^2.0" }, "scripts": { "test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit", @@ -52,7 +56,15 @@ "static": [ "Composer\\Config::disableProcessTimeout", "phpstan --memory-limit=1G" - ] + ], + "rector": [ + "./vendor/bin/rector", + "composer run lint-fix" + ], + "generate-class-cache": "@php bin/tenup-framework-generate-class-cache" + }, + "scripts-descriptions": { + "generate-class-cache": "Generate the class-loader cache for one or more directories, e.g. `composer generate-class-cache -- inc/`." }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 519081b..2406e39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,887 +4,39 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c674b2bd34e9e2105a937d69259c184", + "content-hash": "2e94fb31328e23fbe3ad4266f9fd54bf", "packages": [ - { - "name": "amphp/amp", - "version": "v3.1.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/amp.git", - "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9", - "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Future/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A non-blocking concurrency framework for PHP applications.", - "homepage": "https://amphp.org/amp", - "keywords": [ - "async", - "asynchronous", - "awaitable", - "concurrency", - "event", - "event-loop", - "future", - "non-blocking", - "promise" - ], - "support": { - "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.1.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-01-26T16:07:39+00:00" - }, - { - "name": "amphp/byte-stream", - "version": "v2.1.2", - "source": { - "type": "git", - "url": "https://github.com/amphp/byte-stream.git", - "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", - "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/parser": "^1.1", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2.3" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.22.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\ByteStream\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A stream abstraction to make working with non-blocking I/O simple.", - "homepage": "https://amphp.org/byte-stream", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "non-blocking", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-03-16T17:10:27+00:00" - }, - { - "name": "amphp/cache", - "version": "v2.0.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/cache.git", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Cache\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A fiber-aware cache API based on Amp and Revolt.", - "homepage": "https://amphp.org/cache", - "support": { - "issues": "https://github.com/amphp/cache/issues", - "source": "https://github.com/amphp/cache/tree/v2.0.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:38:06+00:00" - }, - { - "name": "amphp/dns", - "version": "v2.4.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/parser": "^1", - "amphp/process": "^2", - "daverandom/libdns": "^2.0.2", - "ext-filter": "*", - "ext-json": "*", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Dns\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Wright", - "email": "addr@daverandom.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "Async DNS resolution for Amp.", - "homepage": "https://github.com/amphp/dns", - "keywords": [ - "amp", - "amphp", - "async", - "client", - "dns", - "resolve" - ], - "support": { - "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-01-19T15:43:40+00:00" - }, - { - "name": "amphp/parallel", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/parallel.git", - "reference": "5113111de02796a782f5d90767455e7391cca190" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/5113111de02796a782f5d90767455e7391cca190", - "reference": "5113111de02796a782f5d90767455e7391cca190", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/parser": "^1", - "amphp/pipeline": "^1", - "amphp/process": "^2", - "amphp/serialization": "^1", - "amphp/socket": "^2", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "type": "library", - "autoload": { - "files": [ - "src/Context/functions.php", - "src/Context/Internal/functions.php", - "src/Ipc/functions.php", - "src/Worker/functions.php" - ], - "psr-4": { - "Amp\\Parallel\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" - } - ], - "description": "Parallel processing component for Amp.", - "homepage": "https://github.com/amphp/parallel", - "keywords": [ - "async", - "asynchronous", - "concurrent", - "multi-processing", - "multi-threading" - ], - "support": { - "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-12-21T01:56:09+00:00" - }, - { - "name": "amphp/parser", - "version": "v1.1.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/parser.git", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Parser\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A generator parser to make streaming parsers simple.", - "homepage": "https://github.com/amphp/parser", - "keywords": [ - "async", - "non-blocking", - "parser", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/parser/issues", - "source": "https://github.com/amphp/parser/tree/v1.1.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-03-21T19:16:53+00:00" - }, - { - "name": "amphp/pipeline", - "version": "v1.2.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Pipeline\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Asynchronous iterators and operators.", - "homepage": "https://amphp.org/pipeline", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "iterator", - "non-blocking" - ], - "support": { - "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-03-16T16:33:53+00:00" - }, - { - "name": "amphp/process", - "version": "v2.0.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/process.git", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Process\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A fiber-aware process manager based on Amp and Revolt.", - "homepage": "https://amphp.org/process", - "support": { - "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v2.0.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:13:44+00:00" - }, - { - "name": "amphp/serialization", - "version": "v1.0.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Serialization\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Serialization tools for IPC and data storage in PHP.", - "homepage": "https://github.com/amphp/serialization", - "keywords": [ - "async", - "asynchronous", - "serialization", - "serialize" - ], - "support": { - "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" - }, - "time": "2020-03-25T21:39:07+00:00" - }, - { - "name": "amphp/socket", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/dns": "^2", - "ext-openssl": "*", - "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "amphp/process": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php", - "src/SocketAddress/functions.php" - ], - "psr-4": { - "Amp\\Socket\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@gmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", - "homepage": "https://github.com/amphp/socket", - "keywords": [ - "amp", - "async", - "encryption", - "non-blocking", - "sockets", - "tcp", - "tls" - ], - "support": { - "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-21T14:33:03+00:00" - }, - { - "name": "amphp/sync", - "version": "v2.3.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/sync.git", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Sync\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" - } - ], - "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", - "homepage": "https://github.com/amphp/sync", - "keywords": [ - "async", - "asynchronous", - "mutex", - "semaphore", - "synchronization" - ], - "support": { - "issues": "https://github.com/amphp/sync/issues", - "source": "https://github.com/amphp/sync/tree/v2.3.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-08-03T19:31:26+00:00" - }, - { - "name": "daverandom/libdns", - "version": "v2.1.0", - "source": { - "type": "git", - "url": "https://github.com/DaveRandom/LibDNS.git", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "Required for IDN support" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "LibDNS\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "DNS protocol implementation written in pure PHP", - "keywords": [ - "dns" - ], - "support": { - "issues": "https://github.com/DaveRandom/LibDNS/issues", - "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" - }, - "time": "2024-04-12T12:12:48+00:00" - }, { "name": "illuminate/collections", - "version": "v12.3.0", + "version": "v13.23.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "0094b162fa505126c1391222f27fd98734d24525" + "reference": "79b5d04507a255e35ce34cec621696aca84ee242" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/0094b162fa505126c1391222f27fd98734d24525", - "reference": "0094b162fa505126c1391222f27fd98734d24525", + "url": "https://api.github.com/repos/illuminate/collections/zipball/79b5d04507a255e35ce34cec621696aca84ee242", + "reference": "79b5d04507a255e35ce34cec621696aca84ee242", "shasum": "" }, "require": { - "illuminate/conditionable": "^12.0", - "illuminate/contracts": "^12.0", - "illuminate/macroable": "^12.0", - "php": "^8.2" + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" }, "suggest": { - "symfony/var-dumper": "Required to use the dump method (^7.2)." + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -912,29 +64,29 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-16T23:50:18+00:00" + "time": "2026-07-26T13:07:32+00:00" }, { "name": "illuminate/conditionable", - "version": "v12.3.0", + "version": "v13.23.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", - "reference": "a2b3c66f3ca532e12e694bd5c9254adc303b922d" + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/conditionable/zipball/a2b3c66f3ca532e12e694bd5c9254adc303b922d", - "reference": "a2b3c66f3ca532e12e694bd5c9254adc303b922d", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", "shasum": "" }, "require": { - "php": "^8.2" + "php": "^8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -958,31 +110,31 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-02-19T19:08:33+00:00" + "time": "2026-02-25T16:07:55+00:00" }, { "name": "illuminate/contracts", - "version": "v12.3.0", + "version": "v13.23.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "88962e0a73fb837e048ebdbbc67afd2f6b30e8e6" + "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/88962e0a73fb837e048ebdbbc67afd2f6b30e8e6", - "reference": "88962e0a73fb837e048ebdbbc67afd2f6b30e8e6", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/98179ca5d07025c2fe4c5c70e0b57b5284fa0071", + "reference": "98179ca5d07025c2fe4c5c70e0b57b5284fa0071", "shasum": "" }, "require": { - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/simple-cache": "^1.0|^2.0|^3.0" + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -1006,238 +158,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-16T23:56:53+00:00" + "time": "2026-07-15T15:50:17+00:00" }, { "name": "illuminate/macroable", - "version": "v12.3.0", - "source": { - "type": "git", - "url": "https://github.com/illuminate/macroable.git", - "reference": "e862e5648ee34004fa56046b746f490dfa86c613" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613", - "reference": "e862e5648ee34004fa56046b746f490dfa86c613", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "12.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Macroable package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-07-23T16:31:01+00:00" - }, - { - "name": "kelunik/certificate", - "version": "v1.1.3", - "source": { - "type": "git", - "url": "https://github.com/kelunik/certificate.git", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "php": ">=7.0" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^6 | 7 | ^8 | ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Kelunik\\Certificate\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Access certificate details and transform between different formats.", - "keywords": [ - "DER", - "certificate", - "certificates", - "openssl", - "pem", - "x509" - ], - "support": { - "issues": "https://github.com/kelunik/certificate/issues", - "source": "https://github.com/kelunik/certificate/tree/v1.1.3" - }, - "time": "2023-02-03T21:26:53+00:00" - }, - { - "name": "league/uri", - "version": "7.5.1", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", - "shasum": "" - }, - "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" - }, - "conflict": { - "league/uri-schemes": "^1.0" - }, - "suggest": { - "ext-bcmath": "to improve IPV4 host parsing", - "ext-fileinfo": "to create Data URI from file contennts", - "ext-gmp": "to improve IPV4 host parsing", - "ext-intl": "to handle IDN host with the best performance", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "php-64bit": "to improve IPV4 host parsing", - "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "7.x-dev" - } - }, - "autoload": { - "psr-4": { - "League\\Uri\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ignace Nyamagana Butera", - "email": "nyamsprod@gmail.com", - "homepage": "https://nyamsprod.com" - } - ], - "description": "URI manipulation library", - "homepage": "https://uri.thephpleague.com", - "keywords": [ - "data-uri", - "file-uri", - "ftp", - "hostname", - "http", - "https", - "middleware", - "parse_str", - "parse_url", - "psr-7", - "query-string", - "querystring", - "rfc3986", - "rfc3987", - "rfc6570", - "uri", - "uri-template", - "url", - "ws" - ], - "support": { - "docs": "https://uri.thephpleague.com", - "forum": "https://thephpleague.slack.com", - "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" - }, - "funding": [ - { - "url": "https://github.com/sponsors/nyamsprod", - "type": "github" - } - ], - "time": "2024-12-08T08:40:02+00:00" - }, - { - "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "v13.23.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", "shasum": "" }, "require": { - "ext-filter": "*", - "php": "^8.1", - "psr/http-factory": "^1", - "psr/http-message": "^1.1 || ^2.0" - }, - "suggest": { - "ext-bcmath": "to improve IPV4 host parsing", - "ext-gmp": "to improve IPV4 host parsing", - "ext-intl": "to handle IDN host with the best performance", - "php-64bit": "to improve IPV4 host parsing", - "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + "php": "^8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "7.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { "psr-4": { - "League\\Uri\\": "" + "Illuminate\\Support\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1246,45 +194,17 @@ ], "authors": [ { - "name": "Ignace Nyamagana Butera", - "email": "nyamsprod@gmail.com", - "homepage": "https://nyamsprod.com" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", - "homepage": "https://uri.thephpleague.com", - "keywords": [ - "data-uri", - "file-uri", - "ftp", - "hostname", - "http", - "https", - "parse_str", - "parse_url", - "psr-7", - "query-string", - "querystring", - "rfc3986", - "rfc3987", - "rfc6570", - "uri", - "url", - "ws" - ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", "support": { - "docs": "https://uri.thephpleague.com", - "forum": "https://thephpleague.slack.com", - "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" }, - "funding": [ - { - "url": "https://github.com/sponsors/nyamsprod", - "type": "github" - } - ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2026-04-29T09:35:06+00:00" }, { "name": "psr/container", @@ -1340,32 +260,31 @@ "time": "2021-11-05T16:47:00+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "psr/simple-cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "3.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Psr\\SimpleCache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1378,48 +297,49 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "description": "Common interfaces for simple caching", "keywords": [ - "factory", - "http", - "message", + "cache", + "caching", "psr", - "psr-17", - "psr-7", - "request", - "response" + "psr-16", + "simple-cache" ], "support": { - "source": "https://github.com/php-fig/http-factory" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, - "time": "2024-04-15T12:06:14+00:00" + "time": "2021-10-29T13:26:27+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "spatie/laravel-package-tools", + "version": "1.93.1", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Spatie\\LaravelPackageTools\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1428,51 +348,75 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" + "laravel-package-tools", + "spatie" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" }, - "time": "2023-04-04T09:54:51+00:00" + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" }, { - "name": "psr/simple-cache", - "version": "3.0.0", + "name": "spatie/php-structure-discoverer", + "version": "2.4.4", "source": { "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "url": "https://github.com/spatie/php-structure-discoverer.git", + "reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/fa2b7dae8e8a22c0306154c4b052420e054f7e2b", + "reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b", "shasum": "" }, "require": { - "php": ">=8.0.0" + "illuminate/collections": "^11.0|^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.92.7", + "symfony/finder": "^6.0|^7.3.5|^8.0" + }, + "require-dev": { + "amphp/parallel": "^2.3.2", + "illuminate/console": "^11.0|^12.0|^13.0", + "nunomaduro/collision": "^7.0|^8.8.3", + "orchestra/testbench": "^9.5|^10.8|^11.0", + "pestphp/pest": "^3.8|^4.0", + "pestphp/pest-plugin-laravel": "^3.2|^4.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "spatie/laravel-ray": "^1.43.1" + }, + "suggest": { + "amphp/parallel": "When you want to use the Parallel discover worker" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" + "laravel": { + "providers": [ + "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider" + ] } }, "autoload": { "psr-4": { - "Psr\\SimpleCache\\": "src/" + "Spatie\\StructureDiscoverer\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1481,56 +425,59 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" } ], - "description": "Common interfaces for simple caching", + "description": "Automatically discover structures within your PHP application", + "homepage": "https://github.com/spatie/php-structure-discoverer", "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" + "discover", + "laravel", + "php", + "php-structure-discoverer" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "issues": "https://github.com/spatie/php-structure-discoverer/issues", + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.4" }, - "time": "2021-10-29T13:26:27+00:00" + "funding": [ + { + "url": "https://github.com/LaravelAutoDiscoverer", + "type": "github" + } + ], + "time": "2026-06-15T07:14:32+00:00" }, { - "name": "revolt/event-loop", - "version": "v1.0.7", + "name": "symfony/finder", + "version": "v7.4.14", "source": { "type": "git", - "url": "https://github.com/revoltphp/event-loop.git", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3" + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "ext-json": "*", - "jetbrains/phpstorm-stubs": "^2019.3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { "psr-4": { - "Revolt\\": "src" - } + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1538,68 +485,73 @@ ], "authors": [ { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "name": "Cees-Jan Kiewiet", - "email": "ceesjank@gmail.com" + "url": "https://github.com/fabpot", + "type": "github" }, { - "name": "Christian Lück", - "email": "christian@clue.engineering" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "name": "Niklas Keller", - "email": "me@kelunik.com" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "description": "Rock-solid event loop for concurrent PHP applications.", - "keywords": [ - "async", - "asynchronous", - "concurrency", - "event", - "event-loop", - "non-blocking", - "scheduler" - ], - "support": { - "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7" - }, - "time": "2025-01-25T19:27:39+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { - "name": "spatie/laravel-package-tools", - "version": "1.19.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", - "reference": "1c9c30ac6a6576b8d15c6c37b6cf23d748df2faa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", - "php": "^8.0" - }, - "require-dev": { - "mockery/mockery": "^1.5", - "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", - "pestphp/pest": "^1.23|^2.1|^3.1", - "phpunit/phpunit": "^9.5.24|^10.5|^11.5", - "spatie/pest-plugin-test-time": "^1.1|^2.2" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Spatie\\LaravelPackageTools\\": "src" - } + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1607,76 +559,79 @@ ], "authors": [ { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "role": "Developer" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Tools for creating Laravel packages", - "homepage": "https://github.com/spatie/laravel-package-tools", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "laravel-package-tools", - "spatie" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.19.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/spatie", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-02-06T14:58:20+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "spatie/php-structure-discoverer", - "version": "2.3.1", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc", - "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { - "amphp/amp": "^v3.0", - "amphp/parallel": "^2.2", - "illuminate/collections": "^10.0|^11.0|^12.0", - "php": "^8.1", - "spatie/laravel-package-tools": "^1.4.3", - "symfony/finder": "^6.0|^7.0" - }, - "require-dev": { - "illuminate/console": "^10.0|^11.0|^12.0", - "laravel/pint": "^1.0", - "nunomaduro/collision": "^7.0|^8.0", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "pestphp/pest-plugin-laravel": "^2.0|^3.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5|^10.0|^11.5.3", - "spatie/laravel-ray": "^1.26" + "php": ">=7.2" }, "type": "library", "extra": { - "laravel": { - "providers": [ - "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider" - ] + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Spatie\\StructureDiscoverer\\": "src" - } + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1684,58 +639,78 @@ ], "authors": [ { - "name": "Ruben Van Assche", - "email": "ruben@spatie.be", - "role": "Developer" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Automatically discover structures within your PHP application", - "homepage": "https://github.com/spatie/php-structure-discoverer", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "discover", - "laravel", - "php", - "php-structure-discoverer" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { - "url": "https://github.com/LaravelAutoDiscoverer", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-02-14T10:18:38+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/finder", - "version": "v7.2.2", + "name": "symfony/polyfill-php86", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1744,18 +719,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -1766,27 +747,31 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:17+00:00" + "time": "2026-07-02T13:42:24+00:00" } ], "packages-dev": [ { "name": "10up/phpcs-composer", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/10up/phpcs-composer.git", - "reference": "04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688" + "reference": "ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/10up/phpcs-composer/zipball/04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688", - "reference": "04fe5f0d61948f9e38e9cd037a5ee50dcdbf4688", + "url": "https://api.github.com/repos/10up/phpcs-composer/zipball/ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4", + "reference": "ed5f6d4e7dc090338cc86b5925fb961b21e4e4d4", "shasum": "" }, "require": { @@ -1795,7 +780,7 @@ }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "*", - "phpcompatibility/php-compatibility": "dev-develop as 9.99.99" + "phpcompatibility/php-compatibility": "10.0.0-alpha2 as 9.99.99" }, "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", @@ -1811,22 +796,22 @@ "description": "10up's PHP CodeSniffer Ruleset", "support": { "issues": "https://github.com/10up/phpcs-composer/issues", - "source": "https://github.com/10up/phpcs-composer/tree/3.0.0" + "source": "https://github.com/10up/phpcs-composer/tree/3.0.1" }, - "time": "2023-12-14T15:37:22+00:00" + "time": "2025-12-09T17:31:17+00:00" }, { "name": "antecedent/patchwork", - "version": "2.2.1", + "version": "2.2.3", "source": { "type": "git", "url": "https://github.com/antecedent/patchwork.git", - "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245" + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antecedent/patchwork/zipball/1bf183a3e1bd094f231a2128b9ecc5363c269245", - "reference": "1bf183a3e1bd094f231a2128b9ecc5363c269245", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce", "shasum": "" }, "require": { @@ -1859,38 +844,38 @@ ], "support": { "issues": "https://github.com/antecedent/patchwork/issues", - "source": "https://github.com/antecedent/patchwork/tree/2.2.1" + "source": "https://github.com/antecedent/patchwork/tree/2.2.3" }, - "time": "2024-12-11T10:19:54+00:00" + "time": "2025-09-17T09:00:56+00:00" }, { "name": "automattic/vipwpcs", - "version": "3.0.1", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/Automattic/VIP-Coding-Standards.git", - "reference": "2b1d206d81b74ed999023cffd924f862ff2753c8" + "reference": "9c47cd036754e0e5f354a9914568f052043c3f30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/VIP-Coding-Standards/zipball/2b1d206d81b74ed999023cffd924f862ff2753c8", - "reference": "2b1d206d81b74ed999023cffd924f862ff2753c8", + "url": "https://api.github.com/repos/Automattic/VIP-Coding-Standards/zipball/9c47cd036754e0e5f354a9914568f052043c3f30", + "reference": "9c47cd036754e0e5f354a9914568f052043c3f30", "shasum": "" }, "require": { - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.2.1", - "phpcsstandards/phpcsutils": "^1.0.11", - "sirbrillig/phpcs-variable-analysis": "^2.11.18", - "squizlabs/php_codesniffer": "^3.9.2", - "wp-coding-standards/wpcs": "^3.1.0" + "php": ">=7.4", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "sirbrillig/phpcs-variable-analysis": "^2.13.0", + "squizlabs/php_codesniffer": "^3.13.5", + "wp-coding-standards/wpcs": "^3.4.1" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", + "php-parallel-lint/php-parallel-lint": "^1.4.0", "phpcompatibility/php-compatibility": "^9", - "phpcsstandards/phpcsdevtools": "^1.0", - "phpunit/phpunit": "^4 || ^5 || ^6 || ^7 || ^8 || ^9" + "phpcsstandards/phpcsdevtools": "^1.2.3", + "phpunit/phpunit": "^9" }, "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", @@ -1915,31 +900,31 @@ "source": "https://github.com/Automattic/VIP-Coding-Standards", "wiki": "https://github.com/Automattic/VIP-Coding-Standards/wiki" }, - "time": "2024-05-10T20:31:09+00:00" + "time": "2026-07-27T14:33:48+00:00" }, { "name": "brain/monkey", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Brain-WP/BrainMonkey.git", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373" + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", "shasum": "" }, "require": { "antecedent/patchwork": "^2.1.17", - "mockery/mockery": "^1.3.5 || ^1.4.4", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", "php": ">=5.6.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "phpcompatibility/php-compatibility": "^9.3.0", - "phpunit/phpunit": "^5.7.26 || ^6.0 || ^7.0 || >=8.0 <8.5.12 || ^8.5.14 || ^9.0" + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" }, "type": "library", "extra": { @@ -1985,33 +970,33 @@ "issues": "https://github.com/Brain-WP/BrainMonkey/issues", "source": "https://github.com/Brain-WP/BrainMonkey" }, - "time": "2024-08-29T20:15:04+00:00" + "time": "2026-02-05T09:22:14+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.0.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "4be43904336affa5c2f70744a348312336afd0da" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", - "reference": "4be43904336affa5c2f70744a348312336afd0da", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", + "composer-plugin-api": "^2.2", "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { - "composer/composer": "*", + "composer/composer": "^2.2", "ext-json": "*", "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", "yoast/phpunit-polyfills": "^1.0" }, "type": "composer-plugin", @@ -2030,9 +1015,9 @@ "authors": [ { "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl", - "role": "Developer / IT Manager" + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" }, { "name": "Contributors", @@ -2040,7 +1025,6 @@ } ], "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com", "keywords": [ "PHPCodeSniffer", "PHP_CodeSniffer", @@ -2061,9 +1045,28 @@ ], "support": { "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", "source": "https://github.com/PHPCSStandards/composer-installer" }, - "time": "2023-01-05T11:28:13+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" }, { "name": "doctrine/instantiator", @@ -2137,20 +1140,20 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -2158,8 +1161,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -2182,9 +1185,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "mockery/mockery", @@ -2329,22 +1332,112 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2383,9 +1476,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -2507,26 +1600,30 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.7.2", + "version": "v6.9.4", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4" + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/c04f96cb232fab12a3cbcccf5a47767f0665c3f4", - "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", "shasum": "" }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.6.1" + }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "nikic/php-parser": "^4.13", + "nikic/php-parser": "^5.5", "php": "^7.4 || ^8.0", - "php-stubs/generator": "^0.8.3", - "phpdocumentor/reflection-docblock": "^5.4.1", - "phpstan/phpstan": "^1.11", + "php-stubs/generator": "^0.8.6", + "phpdocumentor/reflection-docblock": "^6.0", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5", + "symfony/polyfill-php80": "*", "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" }, @@ -2549,22 +1646,22 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.2" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" }, - "time": "2025-02-12T04:51:58+00:00" + "time": "2026-05-01T20:36:01+00:00" }, { "name": "php-stubs/wp-cli-stubs", - "version": "v2.11.0", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/php-stubs/wp-cli-stubs.git", - "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007" + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/f27ff9e8e29d7962cb070e58de70dfaf63183007", - "reference": "f27ff9e8e29d7962cb070e58de70dfaf63183007", + "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d", "shasum": "" }, "require": { @@ -2593,51 +1690,39 @@ ], "support": { "issues": "https://github.com/php-stubs/wp-cli-stubs/issues", - "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.11.0" + "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0" }, - "time": "2024-11-25T10:09:13+00:00" + "time": "2025-06-10T09:58:05+00:00" }, { "name": "phpcompatibility/php-compatibility", - "version": "dev-develop", + "version": "9.3.5", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "9013cd039fe5740953f9fdeebd19d901b80e26f2" + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9013cd039fe5740953f9fdeebd19d901b80e26f2", - "reference": "9013cd039fe5740953f9fdeebd19d901b80e26f2", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", "shasum": "" }, "require": { - "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.0.12", - "squizlabs/php_codesniffer": "^3.10.0" + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" }, - "replace": { - "wimg/php-compatibility": "*" + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" }, "require-dev": { - "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcsstandards/phpcsdevcs": "^1.1.3", - "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4 || ^10.1.0", - "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0" + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." }, - "default-branch": true, "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "9.x-dev", - "dev-develop": "10.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "LGPL-3.0-or-later" @@ -2663,46 +1748,26 @@ "keywords": [ "compatibility", "phpcs", - "standards", - "static analysis" + "standards" ], "support": { "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", - "security": "https://github.com/PHPCompatibility/PHPCompatibility/security/policy", "source": "https://github.com/PHPCompatibility/PHPCompatibility" }, - "funding": [ - { - "url": "https://github.com/PHPCompatibility", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcompatibility", - "type": "thanks_dev" - } - ], - "time": "2025-01-20T20:06:48+00:00" + "time": "2019-12-27T09:44:58+00:00" }, { "name": "phpcompatibility/phpcompatibility-paragonie", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac" + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/293975b465e0e709b571cbf0c957c6c0a7b9a2ac", - "reference": "293975b465e0e709b571cbf0c957c6c0a7b9a2ac", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", "shasum": "" }, "require": { @@ -2759,27 +1824,32 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" } ], - "time": "2024-04-24T21:30:46+00:00" + "time": "2025-09-19T17:43:28+00:00" }, { "name": "phpcompatibility/phpcompatibility-wp", - "version": "2.1.6", + "version": "2.1.8", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", - "reference": "80ccb1a7640995edf1b87a4409fa584cd5869469" + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/80ccb1a7640995edf1b87a4409fa584cd5869469", - "reference": "80ccb1a7640995edf1b87a4409fa584cd5869469", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", "shasum": "" }, "require": { "phpcompatibility/php-compatibility": "^9.0", - "phpcompatibility/phpcompatibility-paragonie": "^1.0" + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0" @@ -2829,35 +1899,39 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" } ], - "time": "2025-01-16T22:34:19+00:00" + "time": "2025-10-18T00:05:59+00:00" }, { "name": "phpcsstandards/phpcsextra", - "version": "1.2.1", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", - "reference": "11d387c6642b6e4acaf0bd9bf5203b8cca1ec489", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.0.9", - "squizlabs/php_codesniffer": "^3.8.0" + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcsstandards/phpcsdevcs": "^1.1.6", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", "phpcsstandards/phpcsdevtools": "^1.2.1", - "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" }, "type": "phpcodesniffer-standard", "extra": { @@ -2907,35 +1981,39 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2023-12-08T16:49:07+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.0.12", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "87b233b00daf83fb70f40c9a28692be017ea7c6c" + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/87b233b00daf83fb70f40c9a28692be017ea7c6c", - "reference": "87b233b00daf83fb70f40c9a28692be017ea7c6c", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.10.0 || 4.0.x-dev@dev" + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { "ext-filter": "*", "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcsstandards/phpcsdevcs": "^1.1.6", - "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0" + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" }, "type": "phpcodesniffer-standard", "extra": { @@ -2972,6 +2050,7 @@ "phpcodesniffer-standard", "phpcs", "phpcs3", + "phpcs4", "standards", "static analysis", "tokens", @@ -2995,22 +2074,26 @@ { "url": "https://opencollective.com/php_codesniffer", "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-05-20T13:34:27+00:00" + "time": "2026-07-27T10:28:41+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -3042,22 +2125,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.8", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f" - }, + "version": "2.2.7", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9adff3b87c03b12cc7e46a30a524648e497758f", - "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921", + "reference": "692db47b9dddb0487934e5236e77d48594aef921", "shasum": "" }, "require": { @@ -3080,6 +2158,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -3102,30 +2191,31 @@ "type": "github" } ], - "time": "2025-03-09T09:30:48+00:00" + "time": "2026-07-29T17:39:32+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "2.0.1", + "version": "2.0.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "1cc1259cb91ee4cfbb5c39bca9f635f067c910b4" + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/1cc1259cb91ee4cfbb5c39bca9f635f067c910b4", - "reference": "1cc1259cb91ee4cfbb5c39bca9f635f067c910b4", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/67bedd65c24bc72840afc45aed48b1059dd44bec", + "reference": "67bedd65c24bc72840afc45aed48b1059dd44bec", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.1.39" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -3145,11 +2235,14 @@ "MIT" ], "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.1" + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.5" }, - "time": "2024-11-28T21:56:36+00:00" + "time": "2026-07-22T06:50:43+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3472,25 +2565,25 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.33", + "version": "9.6.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fea06253ecc0a32faf787bd31b261f56f351d049" + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fea06253ecc0a32faf787bd31b261f56f351d049", - "reference": "fea06253ecc0a32faf787bd31b261f56f351d049", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -3555,31 +2648,75 @@ "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.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "rector/rector", + "version": "2.5.8", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/861c77fd2a6d45317a401f4e0b617f947e8b6f96", + "reference": "861c77fd2a6d45317a401f4e0b617f947e8b6f96", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.2.6" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.5.8" + }, + "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/tomasvotruba", "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/phpunit/phpunit", - "type": "tidelift" } ], - "time": "2026-01-27T05:25:09+00:00" + "time": "2026-07-27T06:19:16+00:00" }, { "name": "sebastian/cli-parser", @@ -4594,28 +3731,27 @@ }, { "name": "sirbrillig/phpcs-variable-analysis", - "version": "v2.12.0", + "version": "v2.13.0", "source": { "type": "git", "url": "https://github.com/sirbrillig/phpcs-variable-analysis.git", - "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7" + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/4debf5383d9ade705e0a25121f16c3fecaf433a7", - "reference": "4debf5383d9ade705e0a25121f16c3fecaf433a7", + "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/a15e970b8a0bf64cfa5e86d941f5e6b08855f369", + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369", "shasum": "" }, "require": { "php": ">=5.4.0", - "squizlabs/php_codesniffer": "^3.5.6" + "squizlabs/php_codesniffer": "^3.5.7 || ^4.0.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0", - "phpcsstandards/phpcsdevcs": "^1.1", - "phpstan/phpstan": "^1.7", + "phpstan/phpstan": "^1.7 || ^2.0", "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.5 || ^7.0 || ^8.0 || ^9.0 || ^10.5.32 || ^11.3.3", - "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0" + "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0 || ^6.0 || ^7.0" }, "type": "phpcodesniffer-standard", "autoload": { @@ -4647,36 +3783,36 @@ "source": "https://github.com/sirbrillig/phpcs-variable-analysis", "wiki": "https://github.com/sirbrillig/phpcs-variable-analysis/wiki" }, - "time": "2025-03-17T16:17:38+00:00" + "time": "2025-09-30T22:22:48+00:00" }, { "name": "slevomat/coding-standard", - "version": "8.16.0", + "version": "8.22.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "7748a4282df19daf966fda1d8c60a8aec803c83a" + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/7748a4282df19daf966fda1d8c60a8aec803c83a", - "reference": "7748a4282df19daf966fda1d8c60a8aec803c83a", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/1dd80bf3b93692bedb21a6623c496887fad05fec", + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.1.2", "php": "^7.4 || ^8.0", - "phpstan/phpdoc-parser": "^2.1.0", - "squizlabs/php_codesniffer": "^3.11.3" + "phpstan/phpdoc-parser": "^2.3.0", + "squizlabs/php_codesniffer": "^3.13.4" }, "require-dev": { - "phing/phing": "3.0.1", + "phing/phing": "3.0.1|3.1.0", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.6", - "phpstan/phpstan-deprecation-rules": "2.0.1", - "phpstan/phpstan-phpunit": "2.0.4", - "phpstan/phpstan-strict-rules": "2.0.3", - "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.9|12.0.4" + "phpstan/phpstan": "2.1.24", + "phpstan/phpstan-deprecation-rules": "2.0.3", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.6", + "phpunit/phpunit": "9.6.8|10.5.48|11.4.4|11.5.36|12.3.10" }, "type": "phpcodesniffer-standard", "extra": { @@ -4700,7 +3836,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.16.0" + "source": "https://github.com/slevomat/coding-standard/tree/8.22.1" }, "funding": [ { @@ -4712,20 +3848,20 @@ "type": "tidelift" } ], - "time": "2025-02-23T18:12:49+00:00" + "time": "2025-09-13T08:53:30+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.12.0", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/2d1b63db139c3c6ea0c927698e5160f8b3b8d630", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -4742,11 +3878,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -4796,20 +3927,20 @@ "type": "thanks_dev" } ], - "time": "2025-03-18T05:04:51+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "szepeviktor/phpstan-wordpress", - "version": "v2.0.1", + "version": "v2.0.3", "source": { "type": "git", "url": "https://github.com/szepeviktor/phpstan-wordpress.git", - "reference": "f7beb13cd22998e3d913fdb897a1e2553ccd637e" + "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/f7beb13cd22998e3d913fdb897a1e2553ccd637e", - "reference": "f7beb13cd22998e3d913fdb897a1e2553ccd637e", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/aa722f037b2d034828cd6c55ebe9e5c74961927e", + "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e", "shasum": "" }, "require": { @@ -4819,6 +3950,7 @@ }, "require-dev": { "composer/composer": "^2.1.14", + "composer/semver": "^3.4", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "php-parallel-lint/php-parallel-lint": "^1.1", "phpstan/phpstan-strict-rules": "^2.0", @@ -4856,9 +3988,9 @@ ], "support": { "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", - "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.1" + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.3" }, - "time": "2024-12-01T02:13:05+00:00" + "time": "2025-09-14T02:58:22+00:00" }, { "name": "theseer/tokenizer", @@ -4910,18 +4042,75 @@ ], "time": "2025-11-17T20:03:58+00:00" }, + { + "name": "tomasvotruba/type-coverage", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/TomasVotruba/type-coverage.git", + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/4087caa4639bdd4c646f2984bc333efeddf69e4b", + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b", + "shasum": "" + }, + "require": { + "nette/utils": "^3.2 || ^4.0", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "config/extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "TomasVotruba\\TypeCoverage\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Measure type coverage of your project", + "keywords": [ + "phpstan-extension", + "static analysis" + ], + "support": { + "issues": "https://github.com/TomasVotruba/type-coverage/issues", + "source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-05-26T08:14:01+00:00" + }, { "name": "wp-coding-standards/wpcs", - "version": "3.1.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7" + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/9333efcbff231f10dfd9c56bb7b65818b4733ca7", - "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", "shasum": "" }, "require": { @@ -4929,17 +4118,17 @@ "ext-libxml": "*", "ext-tokenizer": "*", "ext-xmlreader": "*", - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.2.1", - "phpcsstandards/phpcsutils": "^1.0.10", - "squizlabs/php_codesniffer": "^3.9.0" + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^8.0 || ^9.0" }, "suggest": { "ext-iconv": "For improved results", @@ -4974,20 +4163,20 @@ "type": "custom" } ], - "time": "2024-03-25T16:39:00+00:00" + "time": "2026-07-27T11:53:23+00:00" }, { "name": "yoast/phpunit-polyfills", - "version": "2.0.4", + "version": "2.0.5", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "a0e3e9adecaa352697786cb29bb0f2fcc25f43f5" + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/a0e3e9adecaa352697786cb29bb0f2fcc25f43f5", - "reference": "a0e3e9adecaa352697786cb29bb0f2fcc25f43f5", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", "shasum": "" }, "require": { @@ -4997,7 +4186,7 @@ "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "yoast/yoastcs": "^3.1.0" + "yoast/yoastcs": "^3.2.0" }, "type": "library", "extra": { @@ -5037,26 +4226,17 @@ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2025-02-09T18:25:29+00:00" - } - ], - "aliases": [ - { - "package": "phpcompatibility/php-compatibility", - "version": "dev-develop", - "alias": "9.99.99", - "alias_normalized": "9.99.99.0" + "time": "2025-08-10T05:13:49+00:00" } ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "phpcompatibility/php-compatibility": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.2" + "php": ">=8.3" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docs/Asset-Loading.md b/docs/Asset-Loading.md index a37b67b..f8606dc 100644 --- a/docs/Asset-Loading.md +++ b/docs/Asset-Loading.md @@ -1,7 +1,16 @@ # Asset Loading ## Overview -Use the `TenupFramework\Assets\GetAssetInfo` trait to read dependency and version metadata generated by your build (the `.asset.php` sidecar files). The trait looks for files in: +Use the `TenupFramework\Assets\GetAssetInfo` trait to read dependency and version metadata generated by your build (the `.asset.php` sidecar files). The trait supports two approaches for locating asset files: + +### Prefix-based paths (recommended) +When you specify a prefix in the slug, the trait looks for files directly in the corresponding subdirectory: +- `get_asset_info('css/admin')` → `dist/css/admin.asset.php` +- `get_asset_info('js/admin')` → `dist/js/admin.asset.php` +- `get_asset_info('blocks/my-block')` → `dist/blocks/my-block.asset.php` + +### Fallback behavior +For slugs without prefixes, the trait searches in this order: - `dist/js/{slug}.asset.php` - `dist/css/{slug}.asset.php` - `dist/blocks/{slug}.asset.php` @@ -55,6 +64,19 @@ Notes: - If your build produces multiple variants (e.g., `admin.js` vs `admin.min.js`), you can conditionally enqueue based on `SCRIPT_DEBUG` or `wp_get_environment_type() === 'development'`. ## Enqueuing scripts + +### Using prefix-based paths (recommended) +```php +wp_enqueue_script( + 'tenup_plugin_admin', + YOUR_PLUGIN_URL . 'dist/js/admin.js', + $this->get_asset_info( 'js/admin', 'dependencies' ), + $this->get_asset_info( 'js/admin', 'version' ), + true +); +``` + +### Using fallback behavior ```php wp_enqueue_script( 'tenup_plugin_admin', @@ -68,6 +90,18 @@ wp_enqueue_script( - version: string used for cache busting ## Enqueuing styles + +### Using prefix-based paths (recommended) +```php +wp_enqueue_style( + 'tenup_plugin_admin', + YOUR_PLUGIN_URL . 'dist/css/admin.css', + [], // CSS dependencies are uncommon; pass [] unless needed + $this->get_asset_info( 'css/admin', 'version' ) +); +``` + +### Using fallback behavior ```php wp_enqueue_style( 'tenup_plugin_admin', @@ -78,7 +112,17 @@ wp_enqueue_style( ``` ## Working with blocks -If you build blocks, pass the block slug used by your build tool: + +### Using prefix-based paths (recommended) +```php +$deps = $this->get_asset_info( 'blocks/my-block', 'dependencies' ); +$ver = $this->get_asset_info( 'blocks/my-block', 'version' ); +$handle = 'tenup_my_block'; + +wp_register_script( $handle, YOUR_PLUGIN_URL . 'dist/blocks/my-block.js', $deps, $ver, true ); +``` + +### Using fallback behavior ```php $deps = $this->get_asset_info( 'my-block', 'dependencies' ); $ver = $this->get_asset_info( 'my-block', 'version' ); @@ -88,6 +132,21 @@ wp_register_script( $handle, YOUR_PLUGIN_URL . 'dist/blocks/my-block.js', $deps, ``` The trait automatically checks `dist/blocks/my-block.asset.php` if present. +## Resolving asset conflicts + +When you have JS and CSS assets with the same handle (e.g., both `admin.js` and `admin.css`), use prefix-based paths to avoid conflicts: + +```php +// ❌ Problematic: Both would load the same asset data +$this->get_asset_info( 'admin', 'dependencies' ); // Could load JS instead of CSS data if both CSS and JS files exist with the same name. + +// ✅ Recommended: Explicitly specify the asset type +$this->get_asset_info( 'js/admin', 'dependencies' ); // Always loads JS asset data +$this->get_asset_info( 'css/admin', 'dependencies' ); // Always loads CSS asset data +``` + +This ensures that each asset type gets its correct dependencies and version information. + ## Error handling and fallbacks ```php try { @@ -105,6 +164,8 @@ try { - Keep your dist path stable across environments (use constants for PATH and URL). - Use the version from `.asset.php` for reliable cache busting in production. - For admin-only assets, enqueue on `admin_enqueue_scripts`; for frontend, use `wp_enqueue_scripts`. +- **Use prefix-based paths** (`'js/admin'`, `'css/admin'`, `'blocks/my-block'`) to avoid conflicts when you have assets with the same handle across different types. +- Prefix-based paths are **backwards compatible** - existing code using fallback behavior will continue to work. ## See also - [Docs Home](README.md) diff --git a/docs/Autoloading.md b/docs/Autoloading.md index 0b7038d..40814a5 100644 --- a/docs/Autoloading.md +++ b/docs/Autoloading.md @@ -78,11 +78,17 @@ add_action( 'plugins_loaded', function () { } ); ``` -Environment caching: -- Discovery results are cached only in production and staging environments (per `wp_get_environment_type()`). -- Cache is stored under the directory you pass to `init_classes()`, in a "class-loader-cache" folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`). -- To refresh: delete that folder; it will be rebuilt automatically. -- Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined or when `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` is set to `true`. Use `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true )` in environments that don't support writable file systems. +Class caching (optional, build-time): +- Discovery is fast, but on large codebases you can cache the discovered class list. The cache is **opt-in and produced at build time**: the framework reads it at runtime but never writes it, so it can never go stale on a server. +- With no cache file present (the default), classes are discovered live on every request. This is correct and is the right default for small projects. +- To produce a cache, run the shipped command in your build/deploy pipeline and ship the result as a build artefact: + ```bash + vendor/bin/tenup-framework-generate-class-cache YOUR_PLUGIN_INC + # or, via the Composer alias (see Build and Deployment): + composer generate-class-cache -- inc/ + ``` +- Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live (useful for debugging a suspected stale cache). +- See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model. ## Defining a Module ```php @@ -116,6 +122,7 @@ class YourModule implements ModuleInterface { ## See also - [Docs Home](README.md) - [Modules and Initialization](Modules-and-Initialization.md) +- [Build and Deployment](Build-and-Deployment.md) - [Post Types](Post-Types.md) - [Taxonomies](Taxonomies.md) - [Asset Loading](Asset-Loading.md) diff --git a/docs/BlockRegistrar-Usage.md b/docs/BlockRegistrar-Usage.md new file mode 100644 index 0000000..c181300 --- /dev/null +++ b/docs/BlockRegistrar-Usage.md @@ -0,0 +1,410 @@ +# BlockRegistrar Usage Guide + +The `BlockRegistrar` class provides automatic block registration from `block.json` files. This guide shows how to use it in themes and plugins. + +## Table of Contents + +- [Basic Usage](#basic-usage) +- [Theme Implementation](#theme-implementation) +- [Plugin Implementation](#plugin-implementation) +- [Multiple Directories](#multiple-directories) +- [Block Structure](#block-structure) +- [Advanced Features](#advanced-features) +- [Troubleshooting](#troubleshooting) + +## Basic Usage + +### 1. Extend BlockRegistrar + +Create a class that extends `BlockRegistrar` and implement the required method: + +```php + Array of paths to the blocks directories. + */ + public function get_blocks_directory(): array { + return [ YOUR_BLOCKS_DIRECTORY ]; + } +} +``` + +### 2. Automatic Registration + +The framework automatically discovers and registers your `Blocks` class through `ModuleInitialization`. No manual registration needed! + +## Theme Implementation + +### Directory Structure + +``` +your-theme/ +├── src/ +│ └── Blocks.php +├── blocks/ +│ ├── example-block/ +│ │ ├── block.json +│ │ ├── edit.js +│ │ ├── index.js +│ │ ├── markup.php +│ │ └── save.js +│ └── hero-block/ +│ ├── block.json +│ ├── edit.js +│ ├── index.js +│ ├── markup.php +│ └── save.js +└── functions.php +``` + +### Theme Blocks Class + +```php + Array of paths to the blocks directories. + */ + public function get_blocks_directory(): array { + return [ + get_template_directory() . '/blocks/', + ]; + } +} +``` + +### Block Definition (block.json) + +```json +{ + "name": "your-theme/hero", + "title": "Hero Block", + "description": "A hero section block", + "category": "layout", + "icon": "cover-image", + "keywords": ["hero", "banner", "header"], + "supports": { + "align": ["wide", "full"], + "color": { + "background": true, + "text": true + } + }, + "attributes": { + "title": { + "type": "string", + "default": "Welcome" + }, + "subtitle": { + "type": "string", + "default": "Subtitle text" + } + }, + "editorScript": "file:./build/index.js", + "editorStyle": "file:./build/editor.css", + "style": "file:./build/style.css" +} +``` + +## Plugin Implementation + +### Directory Structure + +``` +your-plugin/ +├── src/ +│ └── Blocks.php +├── blocks/ +│ ├── contact-form/ +│ │ ├── block.json +│ │ ├── edit.js +│ │ ├── index.js +│ │ ├── markup.php +│ │ └── save.js +│ └── testimonials/ +│ ├── block.json +│ ├── edit.js +│ ├── index.js +│ ├── markup.php +│ └── save.js +└── plugin.php +``` + +### Plugin Blocks Class + +```php + Array of paths to the blocks directories. + */ + public function get_blocks_directory(): array { + return [ + plugin_dir_path( __FILE__ ) . 'blocks/', + ]; + } +} +``` + +## Multiple Directories + +You can register blocks from multiple directories: + +```php +public function get_blocks_directory(): array { + return [ + get_template_directory() . '/blocks/', // Theme blocks + get_template_directory() . '/custom-blocks/', // Custom theme blocks + plugin_dir_path( __FILE__ ) . 'vendor-blocks/', // Third-party blocks + ]; +} +``` + +## Block Structure + +### Required Files + +Each block directory must contain: + +- **`block.json`** - Block metadata (required) +- **`index.js`** - Block JavaScript (required) + +### Standard Files (Recommended) + +- **`edit.js`** - Editor component (recommended) +- **`save.js`** - Save component (recommended) +- **`markup.php`** - Server-side rendering (recommended for dynamic blocks) + +### Optional Files + +- **`style.css`** - Block styles +- **`editor.css`** - Editor-only styles + +### Dynamic Blocks with Server-Side Rendering + +For blocks that need server-side rendering, add a `markup.php` file: + +```php + + +
+

+ +

+ +
+ +
+
+``` + +The `BlockRegistrar` automatically detects `markup.php` files and creates render callbacks. + +## Advanced Features + +### Conflict Detection + +The `BlockRegistrar` automatically detects block name conflicts between themes and plugins: + +```php +// Check if a block has conflicts +if ( \TenupFramework\BlockRegistrar::has_block_conflict( 'theme/hero' ) ) { + $source = \TenupFramework\BlockRegistrar::get_block_source( 'theme/hero' ); + error_log( "Block 'theme/hero' already registered by: {$source}" ); +} + +// Get all registered blocks and their sources +$sources = \TenupFramework\BlockRegistrar::get_all_block_sources(); +foreach ( $sources as $block_name => $source_class ) { + echo "Block '{$block_name}' registered by: {$source_class}\n"; +} +``` + +### Error Handling + +The `BlockRegistrar` provides comprehensive error handling: + +- **Invalid directories** - Skipped with error logging +- **Malformed JSON** - Skipped with error logging +- **Missing required fields** - Skipped with error logging +- **Block registration failures** - Logged with details + +### Security Features + +- **Path validation** - Prevents directory traversal attacks +- **JSON validation** - Ensures proper block metadata +- **File permission checks** - Verifies readable directories +- **Block name validation** - Enforces proper naming conventions + +## Troubleshooting + +### Common Issues + +#### 1. Blocks Not Appearing + +**Problem**: Blocks don't appear in the editor. + +**Solutions**: +- Check that `block.json` exists and is valid JSON +- Verify the block name follows `namespace/name` format +- Ensure the directory path is correct +- Check WordPress error logs for registration errors + +#### 2. Block Name Conflicts + +**Problem**: "Block name conflict detected" error. + +**Solutions**: +- Use unique block names (e.g., `theme/hero`, `plugin/form`) +- Check which class registered the conflicting block +- Consider using different namespaces + +#### 3. Server-Side Rendering Not Working + +**Problem**: Dynamic blocks don't render on the frontend. + +**Solutions**: +- Ensure `markup.php` exists in the block directory +- Check that `markup.php` has proper PHP syntax +- Verify the block is registered as dynamic in `block.json` + +#### 4. Styles Not Loading + +**Problem**: Block styles don't appear. + +**Solutions**: +- Check `style.css` path in `block.json` +- Ensure the CSS file exists +- Verify the `style` property is correctly set + +### Debug Information + +Enable WordPress debug logging to see detailed error messages: + +```php +// In wp-config.php +define( 'WP_DEBUG', true ); +define( 'WP_DEBUG_LOG', true ); +``` + +Check `/wp-content/debug.log` for `BlockRegistrar` error messages. + +### Testing Your Implementation + +```php +// Test that your blocks class is working +$blocks = new YourTheme\Blocks(); +$directories = $blocks->get_blocks_directory(); + +// Check if directories exist +foreach ( $directories as $dir ) { + if ( ! file_exists( $dir ) ) { + error_log( "Block directory does not exist: {$dir}" ); + } +} +``` + +## Best Practices + +1. **Use descriptive block names** - `theme/hero` instead of `theme/block1` +2. **Organize blocks logically** - Group related blocks in subdirectories +3. **Include proper metadata** - Complete `block.json` with all required fields +4. **Test thoroughly** - Verify blocks work in both editor and frontend +5. **Handle errors gracefully** - Check error logs regularly +6. **Use consistent naming** - Follow your project's naming conventions + +## Examples + +### Complete Theme Example + +```php + [ ...] +``` + +Pass the same directory you pass to `ModuleInitialization::init_classes()` — usually your +plugin/theme `inc/`. It writes `class-loader-cache/class-loader-cache-v2.php` inside each +directory. Multiple directories may be passed in one call. + +### Composer alias + +For nicer ergonomics, add a one-line script alias to your project's `composer.json`: + +```json +{ + "scripts": { + "generate-class-cache": "tenup-framework-generate-class-cache inc/" + } +} +``` + +Then run it with: + +```bash +composer generate-class-cache +``` + +> Composer does not propagate a dependency's scripts into your project, so the alias lives in +> your own `composer.json`. The `vendor/bin` command is the portable entry point either way. + +### Bypassing the cache + +Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` (e.g. in `wp-config.php`) to ignore any +shipped cache and always discover live. Useful when debugging a suspected stale or incorrect +cache. + +## Gitignore the cache + +The cache is a build artefact, not source. Ignore it and regenerate it on every deploy so an +old copy can never linger: + +```gitignore +# Generated class-loader cache (built in CI, shipped with the deploy) +**/class-loader-cache/ +``` + +## Wiring it into CI + +The generate step always sits **after** `composer install` (it needs the framework and its +dependencies on disk) and **before** the deploy/packaging step (so the cache ships with the +build). `spatie/php-structure-discoverer` and this command are runtime dependencies, so +`composer install --no-dev` keeps them available. + +### GitHub Actions + +```yaml +name: Deploy +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + - name: Install PHP dependencies + run: composer install --no-dev --prefer-dist --no-progress + - name: Generate the class-loader cache + run: composer generate-class-cache + # or: vendor/bin/tenup-framework-generate-class-cache inc/ + - name: Deploy + run: ./bin/deploy.sh # your deploy ships inc/class-loader-cache/ with the build +``` + +### GitLab CI + +```yaml +stages: + - build + - deploy + +build: + stage: build + image: php:8.3 + script: + - composer install --no-dev --prefer-dist --no-progress + - vendor/bin/tenup-framework-generate-class-cache inc/ + artifacts: + paths: + - vendor/ + - inc/class-loader-cache/ + +deploy: + stage: deploy + script: + - ./bin/deploy.sh # ships the artifacts produced by the build stage +``` + +### CircleCI + +```yaml +version: 2.1 + +jobs: + build-and-deploy: + docker: + - image: cimg/php:8.3 + steps: + - checkout + - run: + name: Install PHP dependencies + command: composer install --no-dev --prefer-dist --no-progress + - run: + name: Generate the class-loader cache + command: vendor/bin/tenup-framework-generate-class-cache inc/ + - run: + name: Deploy + command: ./bin/deploy.sh # ships inc/class-loader-cache/ with the build + +workflows: + deploy: + jobs: + - build-and-deploy: + filters: + branches: + only: main +``` + +If the build can't run the generate step for some reason, the deploy still works — it just +runs uncached. A broken cache after a build means the build is the thing to fix, not the +server. A cache file that is corrupt or truncated (a half-finished rsync, an interrupted +build) is caught at runtime and the request falls back to a live scan, so a bad cache slows +the site rather than taking it down. + +### Opcache and in-place deploys + +The cache is a PHP file loaded with `require`, so PHP's opcache caches it like any other +source file. On hosts with `opcache.validate_timestamps=0` (common on the managed hosts where +issue #30 was reported), overwriting `class-loader-cache-v2.php` **in place** keeps serving the +previously compiled array until opcache is reset — which would reintroduce the very staleness +this design removes. Either deploy to a fresh path (atomic symlink swap, the default on most +zero-downtime deployers) or reset opcache as part of the deploy. The loader debug page's +staleness check live-scans and will flag this if it happens. + +## The per-package model + +The framework discovers classes from **one directory mapped to one namespace, per Composer +package**. Each plugin or theme that uses the framework is its own unit: its own namespace, +its own `inc/` (or `src/`) directory, its own cache, and it requires the framework via +Composer in its own `composer.json`. + +This is why caching is per package rather than per project. A single cache at the project +root could not tell which discovered classes belong to which plugin without scanning +everything — which is exactly the work the cache exists to avoid. So the cache is generated +per package, into the directory passed to that package's `init_classes()`, and shipped inside +that package. + +The trade-off is that each package carries its own `vendor/` (a duplicated framework install) +and its own CI generate step, in exchange for domain-focused units that decouple cleanly. If +your build produces several packages, generate each one's cache — either with a call per +package, or by passing every directory to a single invocation: + +```bash +vendor/bin/tenup-framework-generate-class-cache \ + wp-content/plugins/foo/inc \ + wp-content/plugins/bar/inc +``` + +One caveat for a mono-repo where packages pin **different** framework versions: the single +invocation above uses one package's `vendor/bin` copy to write every directory's cache. That +copy determines the cache filename and the Spatie discoverer version used. Today the payload is +a plain array of class-name strings and the filename is identical across versions, so this is +safe — but a future cache-format or filename bump would silently mismatch. When packages are on +different framework versions, run **each package's own** `vendor/bin/tenup-framework-generate-class-cache` +against its own directory so the writer and the reader are always the same version. + +## See also +- [Docs Home](README.md) +- [Autoloading and Modules](Autoloading.md) +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/docs/Core-Modules.md b/docs/Core-Modules.md new file mode 100644 index 0000000..cac4359 --- /dev/null +++ b/docs/Core-Modules.md @@ -0,0 +1,180 @@ +# Core Modules + +WP Framework provides several Core modules that handle common WordPress functionality modifications. These modules are designed to be explicitly opted into by consuming applications (themes/plugins) rather than being automatically initialized. + +## Available Core Modules + +### HeadOverrides + +The `HeadOverrides` module removes unwanted WordPress head elements that are typically not needed in production sites. + +**Location**: `TenupFramework\Core\HeadOverrides` + +**Functionality**: +- Removes WordPress generator meta tag (`wp_generator`) +- Removes Windows Live Writer manifest link (`wlwmanifest_link`) +- Removes Really Simple Discovery service endpoint link (`rsd_link`) + +**Usage**: +```php +// Recommended: Array-based approach +$core_modules = [ \TenupFramework\Core\HeadOverrides::class ]; +foreach ( $core_modules as $module_class ) { + $module = new $module_class(); + if ( $module->can_register() ) { + $module->register(); + } +} +``` + +### Emoji + +The `Emoji` module disables WordPress core emoji functionality, which can improve performance by removing unnecessary scripts and styles. + +**Location**: `TenupFramework\Core\Emoji` + +**Functionality**: +- Removes emoji detection scripts from `wp_head` and admin +- Removes emoji-related styles from front-end and back-end +- Removes emoji-to-static-img conversion from feeds and email +- Disables TinyMCE emoji plugin +- Removes emoji CDN from DNS prefetching hints + +**Usage**: +```php +// Recommended: Array-based approach +$core_modules = [ \TenupFramework\Core\Emoji::class ]; +foreach ( $core_modules as $module_class ) { + $module = new $module_class(); + if ( $module->can_register() ) { + $module->register(); + } +} +``` + +## Core Module Characteristics + +All Core modules follow these patterns: + +### Module Interface Compliance +- Implement `TenupFramework\ModuleInterface` +- Use the `TenupFramework\Module` trait +- Provide `load_order()`, `can_register()`, and `register()` methods + +### Load Order +Core modules typically use a load order of `5`, ensuring they initialize early in the module lifecycle. + +### Explicit Opt-In +Core modules are **not automatically initialized** by the framework. Consuming applications must explicitly instantiate and register them. + +## Integration Patterns + +### Recommended: Array-Based Approach +The cleanest way to initialize Core modules is using an array and foreach loop: + +```php +// Define the Core modules you want to use +$core_modules = [ + \TenupFramework\Core\HeadOverrides::class, + \TenupFramework\Core\Emoji::class, +]; + +// Initialize each module +foreach ( $core_modules as $module_class ) { + $module = new $module_class(); + if ( $module->can_register() ) { + $module->register(); + } +} +``` + +### Manual Instantiation (Alternative) +```php +// Initialize specific Core modules individually +$head_overrides = new \TenupFramework\Core\HeadOverrides(); +if ( $head_overrides->can_register() ) { + $head_overrides->register(); +} + +$emoji = new \TenupFramework\Core\Emoji(); +if ( $emoji->can_register() ) { + $emoji->register(); +} +``` + +### Configuration-Based Approach (Future Enhancement) +If using the configuration-based initialization pattern: + +```php +// Using ModuleInitialization::init_specific_modules() +ModuleInitialization::instance()->init_specific_modules([ + \TenupFramework\Core\HeadOverrides::class, + \TenupFramework\Core\Emoji::class, +]); +``` + +## Best Practices + +### When to Use Core Modules +- **HeadOverrides**: Use when you want to remove WordPress generator meta and other unnecessary head elements +- **Emoji**: Use when you want to disable WordPress emoji functionality for performance reasons + +### Conditional Loading +Consider loading Core modules conditionally based on your application's needs: + +```php +// Build array of Core modules based on conditions +$core_modules = [ \TenupFramework\Core\HeadOverrides::class ]; + +// Only load emoji module in production +if ( wp_get_environment_type() === 'production' ) { + $core_modules[] = \TenupFramework\Core\Emoji::class; +} + +// Initialize all selected modules +foreach ( $core_modules as $module_class ) { + $module = new $module_class(); + if ( $module->can_register() ) { + $module->register(); + } +} +``` + +### Testing +Core modules include comprehensive test suites that verify: +- Interface implementation +- Method existence and callability +- Source code verification of WordPress function calls +- Functional behavior testing + +## Migration from Scaffold + +If migrating from the WP Scaffold plugin's Core modules: + +1. **Remove** the old Core module classes from your scaffold +2. **Add explicit opt-in** to the framework's Core modules +3. **Test** that functionality works as expected +4. **Verify** no regressions in your application + +## Future Enhancements + +### Framework Helper Method +A future enhancement could add a helper method to the `ModuleInitialization` class: + +```php +// Potential future API +ModuleInitialization::init_core_modules([ + \TenupFramework\Core\HeadOverrides::class, + \TenupFramework\Core\Emoji::class, +]); +``` + +This would internally handle the instantiation and registration logic, making the array-based approach even cleaner. + +## Future Core Modules + +The framework is designed to accommodate additional Core modules as needed. New modules should follow the same patterns: +- Implement `ModuleInterface` +- Use the `Module` trait +- Provide comprehensive test coverage +- Require explicit opt-in from consuming applications diff --git a/docs/Debugging.md b/docs/Debugging.md new file mode 100644 index 0000000..18de671 --- /dev/null +++ b/docs/Debugging.md @@ -0,0 +1,103 @@ +# Debugging class loaders + +The framework ships a hidden admin page that shows the state of every class-loader cache active +on a site. It exists to answer one question quickly: **what is each loader actually loading, and +is any cache stale?** That matters most on production, where a failed build or a missed deploy can +leave an old cache file in place (see [Build and Deployment](Build-and-Deployment.md) and +[issue #30](https://github.com/10up/wp-framework/issues/30)). + +## Opening the page + +There is no menu item — the page is hidden. Visit it directly: + +``` +/wp-admin/admin.php?page=tenup-framework-loaders +``` + +It requires the `manage_options` capability. + +## What it shows + +A site can run **1..n** framework copies (one per plugin/theme that requires the package). The +page aggregates every loader recorded across all of them — even copies that are php-scoped +(prefixed) or on different versions — by collecting over the fixed-string +`tenup_framework_debug_loaders` filter. For each loader you get: + +- **Owner** — the plugin or theme the directory belongs to (derived from the path). +- **Directory** — the directory passed to `ModuleInitialization::init_classes()`. +- **Framework version** — version and git reference of the copy that recorded it, so a + version mismatch between plugins is visible. +- **Cache file** — its path, and the status: in use, present-but-not-used, discovering live + (no file), disabled, or **failed to load** (a present cache that was corrupt or truncated, so + the runtime fell back to a live scan — badged red). When a file is present, its age and size. +- **Stale cache files** — a warning if the cache directory holds files other than the current + one (usually leftovers from an older framework version). +- **Classes loaded** — every class the loader resolved, with the file each one lives in. A class + that no longer resolves is flagged as a likely stale entry. +- **Discovery time** — how long this request spent obtaining the class list. With a cache present + this is the cost of reading it; uncached it is the cost of a live filesystem scan, so the two + states can be compared directly. +- **Class lookup time** — how long reflecting, instantiating and registering the discovered + classes took. + +## Staleness check + +Each loader has a **Check this cache for staleness** button. It re-runs discovery live against the +directory and diffs the result against what the cache loaded, listing: + +- classes **on disk but missing from the cache** (the cache is behind), and +- classes **in the cache but no longer on disk** (renamed/removed). + +It also reports **how long the live discovery took**, which — compared against the cached +**Discovery time** above — shows what the cache is actually saving on this site. The check runs +only when clicked, so the page itself stays cheap. If it reports drift, the cache +is stale: regenerate it in your build (`composer generate-class-cache`) or remove the file and +redeploy. The page is **read-only** — it never deletes or rewrites a cache, consistent with the +read-only runtime. + +## Performance + +The recording and the page are **admin-only**. On front-end requests nothing is recorded, no hooks +are added, and the debug class is never even loaded. + +## Corrupt cache + +If a shipped cache file is corrupt or truncated (a partial deploy or half-written rsync), the +runtime catches it and falls back to a live scan rather than fataling the request. The debug page +flags that loader red as **"Cache failed to load — running live"** so the degraded state is +visible rather than looking healthy. To alert or log on it outside the admin, hook the action that +fires on every fallback: + +```php +add_action( 'tenup_framework_cache_load_failed', function ( $dir, $error ) { + error_log( "WP Framework: class cache for {$dir} failed to load: " . $error->getMessage() ); +}, 10, 2 ); +``` + +The fix is to rebuild the cache in your pipeline and redeploy. + +## Known limitations + +- **Per request** — the page shows loaders recorded on the current admin request. A plugin whose + `init_classes()` did not run on this request will not appear. +- **Mixed framework versions render with the oldest UI** — when a mono-repo runs several framework + copies on different versions, the first copy to record a loader registers and renders the page, + so newer per-loader fields degrade to blank rather than showing. Aligning framework versions + across packages avoids this; the data itself is still aggregated correctly across all copies. + +## Disabling it + +Enabled by default in the admin. Turn it off with either: + +```php +add_filter( 'tenup_framework_enable_loader_debug', '__return_false' ); +``` + +```php +define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true ); +``` + +## See also +- [Docs Home](README.md) +- [Build and Deployment](Build-and-Deployment.md) +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/docs/Modules-and-Initialization.md b/docs/Modules-and-Initialization.md index e00a34d..6869783 100644 --- a/docs/Modules-and-Initialization.md +++ b/docs/Modules-and-Initialization.md @@ -23,8 +23,7 @@ ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC ); ModuleInitialization performs the following steps: 1. Validate the directory exists; otherwise throw a RuntimeException. 2. Discover class names within the directory using spatie/structure-discoverer. - - In production and staging environments (wp_get_environment_type), results are cached for performance using a file-based cache. - - Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined. + - If a pre-built class cache is present it is read; otherwise classes are discovered live on every request. The runtime never writes the cache — see [Class caching](#class-caching) below. 3. Reflect on each discovered class and skip any that: - are not instantiable, - do not implement `TenupFramework\ModuleInterface`. @@ -35,12 +34,16 @@ ModuleInitialization performs the following steps: 7. For each module, call `register()` only if `can_register()` returns true. 8. Store initialized modules for later retrieval. -Environment cache behavior -- Where cache lives: under the directory you pass to `init_classes()`, in a `class-loader-cache` folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`). -- When it’s used: only in `production` and `staging` environment types (`wp_get_environment_type()`). -- How to clear: delete the `class-loader-cache` folder; it will be rebuilt on next discovery. -- How to disable in development: use `development` or `local` environment types, or define `VIP_GO_APP_ENVIRONMENT` to skip the cache. -- How to disable for hosts that don't support file-based caching: `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true );` to skip caching altogether. +## Class caching +Caching the discovered class list is **optional and produced at build time**. The runtime only ever reads the cache; it never writes one, so a server cannot end up serving a stale cache it generated itself (the failure mode this model replaced — see [issue #30](https://github.com/10up/wp-framework/issues/30)). + +- Default (no cache file): classes are discovered live on every request. Correct, and the right default for small codebases — caching is opt-in. +- With a cache file present: the framework reads it and skips discovery. +- Where it lives: a `class-loader-cache` folder inside the directory you pass to `init_classes()`, e.g. `YOUR_PLUGIN_INC . 'class-loader-cache'`. The filename is versioned (`class-loader-cache-v2.php`) so a cache written by an older framework version is ignored after an upgrade rather than served stale; the old file is harmless cruft a clean deploy clears. +- How to produce it: run `vendor/bin/tenup-framework-generate-class-cache ` (or `composer generate-class-cache -- `) in your build/deploy pipeline and ship the result as a build artefact. +- How to bypass: define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live. + +Gitignore the `class-loader-cache` directory and regenerate it on every deploy. See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model, and [Debugging class loaders](Debugging.md) for the hidden admin page that shows what each cache is loading and flags stale ones. Hooks - Action: `tenup_framework_module_init__{slug}` — fires before each module’s `register()` runs. diff --git a/docs/README.md b/docs/README.md index 2cb283c..d959d9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ WP Framework is a lightweight set of building blocks for structuring WordPress p - Base classes for custom and core post types - Base class for taxonomies - Asset helpers that read modern build sidecars (`.asset.php`) +- Core modules for common WordPress functionality modifications ## Who is this for? - External engineers — Start here: follow the Quick Start and then read Autoloading and Modules → Modules and Initialization → Post Types/Taxonomies → Asset Loading. @@ -17,13 +18,18 @@ WP Framework is a lightweight set of building blocks for structuring WordPress p 3) Initialize modules: `TenupFramework\ModuleInitialization::instance()->init_classes( YOUR_PLUGIN_INC )`. 4) Implement small classes that implement `ModuleInterface` (use the `Module` trait) and optionally extend `AbstractPostType` / `AbstractTaxonomy`. 5) Load assets via the `GetAssetInfo` trait using dist/.asset.php sidecars. +6) Optionally opt into Core modules for common WordPress functionality modifications. ## Table of Contents +- [Upgrade Guide](Upgrade-Guide.md) — breaking changes and how to migrate (start here when updating a major version) - [Autoloading and Modules](Autoloading.md) — how classes are discovered and initialized - [Modules and Initialization](Modules-and-Initialization.md) +- [Build and Deployment](Build-and-Deployment.md) — generating the class cache and wiring it into CI +- [Debugging class loaders](Debugging.md) — the hidden admin page for inspecting caches - [Post Types](Post-Types.md) — building custom and core post type integrations - [Taxonomies](Taxonomies.md) — registering and configuring taxonomies - [Asset Loading](Asset-Loading.md) — working with dist/.asset.php for dependencies and versioning +- [Core Modules](Core-Modules.md) — WordPress functionality modification modules ## Conventions - Namespaces: use your project namespace (e.g., `YourVendor\\YourPlugin`) for app code; reference framework classes via the TenupFramework namespace. diff --git a/docs/Upgrade-Guide.md b/docs/Upgrade-Guide.md new file mode 100644 index 0000000..30626a2 --- /dev/null +++ b/docs/Upgrade-Guide.md @@ -0,0 +1,204 @@ +# Upgrade Guide + +## Upgrading to 2.0 + +2.0 is a **breaking release** with three independent breaking changes: + +1. **PHP 8.3 is now the minimum.** Composer will refuse to install on 8.2. See below. +2. **The class-loader cache no longer generates automatically at runtime.** For the background on + *why*, see [issue #30](https://github.com/10up/wp-framework/issues/30) — the automatic runtime + cache could go stale on a server and could only be cleared by hand. +3. **`get_name()` on the post type and taxonomy base classes declares a narrower return type.** + Static-analysis only, no runtime change. See below. + +This page covers what changed, who is affected, and exactly what to do. + +## PHP 8.3 is required + +`composer require 10up/wp-framework:^2.0` fails on PHP 8.2 with an unsatisfiable platform +requirement. There is no partial upgrade path and no flag to work around it: the package source +contains typed class constants, which are a **parse error** on 8.2, so a forced install would +fatal at autoload time rather than degrade. + +**What to do:** confirm every environment that runs your project — local, CI, staging, +production — is on PHP 8.3 or newer before upgrading. + +```bash +php -v # locally +wp --info # on the server, via WP-CLI +``` + +If any environment is still on 8.2, stay on `^1.2` until it is upgraded. 1.2.x continues to +support 8.2. + +> **Why the requirement moved back up.** 1.2.0 lowered the minimum from 8.3 to 8.2 +> ([#8](https://github.com/10up/wp-framework/pull/8)). 2.0 raises it again because the framework +> now declares native types on every class constant, which PHP only allows from 8.3. If the 8.2 +> floor matters more to your projects than static type coverage does, open an issue — the +> trade-off is worth discussing rather than assuming. + +## `get_name()` declares a narrower return type + +`AbstractPostType::get_name()` and `AbstractTaxonomy::get_name()` keep their native `: string` +signature, but the documented contract is now: + +```php +/** + * @return lowercase-string&non-empty-string + */ +abstract public function get_name(): string; +``` + +This states what WordPress has always required of a post type or taxonomy key: non-empty and +lowercase. `register_post_type()` and `register_taxonomy()` reject anything else. + +**Nothing changes at runtime.** No method signature changed, so no subclass breaks and no +behaviour differs. This only affects static analysis. + +### Are you affected? + +Only if you run PHPStan or Psalm over your own project **and** a `get_name()` implementation +returns a value the analyser cannot prove is lowercase and non-empty. + +Not flagged — a literal is provably both: + +```php +public function get_name(): string { + return 'landing_page'; +} +``` + +Flagged — nothing narrows `$name`: + +```php +public function get_name(): string { + return get_option( 'my_cpt_slug' ); // could be any string, or not a string at all +} +``` + +**Fix it where the value is built, not at the return:** + +```php +public function get_name(): string { + $name = strtolower( (string) get_option( 'my_cpt_slug' ) ); + + return '' !== $name ? $name : 'landing_page'; +} +``` + +`strtolower()` gives the analyser `lowercase-string`, and the `'' !== $name` check gives +`non-empty-string`, so the contract is satisfied. + +If an implementation was returning a mixed-case or empty key, WordPress was already rejecting it. +The tightened contract surfaces an existing bug rather than creating a new one. + +## Who is affected by the cache change + +You are affected if, on 1.x, you relied on the cache being **built automatically in production +or staging**. After upgrading, that no longer happens — the framework reads a pre-built cache if +one is present and otherwise **discovers classes live on every request**. + +- Small projects (a handful of Modules): running uncached is fine. No action needed beyond the + cleanup below. +- Large codebases that want the performance of a cache: you must now **produce the cache as a + build step** (see [Build and Deployment](Build-and-Deployment.md)). If you upgrade without + adding that step, the site keeps working but runs uncached — slower discovery on every request. + +> There is no runtime warning when running uncached. To confirm whether a cache is actually in +> use after upgrading, use the [loader debug page](Debugging.md) — see *Verify the upgrade* below. + +## What changed + +### Caching is now read-only at runtime and opt-in + +- **1.x:** the first request on a production/staging server discovered classes and **wrote** the + cache; later requests read it (and never re-checked it — the stale-cache bug). +- **2.0:** the runtime **only ever reads** a cache. It never writes one. A cache is produced at + build time with the shipped command and shipped as a build artefact. + +### Removed + +- `should_use_cache()` and its environment gating. +- The automatic `production` / `staging` caching behaviour (via `wp_get_environment_type()`). +- `VIP_GO_APP_ENVIRONMENT` handling. It no longer affects caching. VIP sites already ran uncached + in practice, so there is nothing to do unless you want to opt into the build step. + +### Changed + +- **`TENUP_FRAMEWORK_DISABLE_CLASS_CACHE`** still disables caching, but its meaning is now "ignore + any shipped cache and always discover live." If you set it on 1.x to avoid caching, it keeps + doing what you want — no change needed. +- The cache file name is versioned: `class-loader-cache/class-loader-cache-v2.php`. The runtime + looks only for this name, so a cache written by 1.x is ignored rather than served stale. + +## What you need to do + +### 1. Decide: uncached or build-time cache + +- **Run uncached (default):** do nothing. Discovery runs on every request. Correct, and fine for + most projects. +- **Keep a cache:** add the generate step to your build/deploy pipeline. Full instructions and + CI examples (GitHub Actions, GitLab CI, CircleCI) are in + [Build and Deployment](Build-and-Deployment.md). In short: + + ```bash + # Run after `composer install`, before deploy/packaging: + vendor/bin/tenup-framework-generate-class-cache inc/ + ``` + + Pass the same directory you pass to `ModuleInitialization::init_classes()`. If you have several + plugins/themes using the framework, generate each one's cache (the command accepts multiple + directories). + + > A `composer generate-class-cache` alias is convenient, but Composer does not expose a + > dependency's scripts to your project — add the alias to **your own** `composer.json` if you + > want it. The `vendor/bin` command is the portable entry point. See + > [Build and Deployment](Build-and-Deployment.md#composer-alias). + +### 2. Clean up old cache files + +The cache directory is a build artefact, not source. Add it to `.gitignore` and let a clean +deploy clear stale copies: + +```gitignore +# Generated class-loader cache (built in CI, shipped with the deploy) +**/class-loader-cache/ +``` + +Any old `discoverer-cache-*` file left by 1.x is harmless — the 2.0 runtime never reads it — but +you can delete the `class-loader-cache` directory to remove the clutter; it will be regenerated by +your build if you opted into caching. + +### 3. Verify the upgrade + +Because nothing in the runtime announces whether caching is on, confirm it explicitly with the +[loader debug page](Debugging.md): + +``` +/wp-admin/admin.php?page=tenup-framework-loaders +``` + +For each loader it shows a **Cache status** line: + +- *"discovering live on every request"* — no cache is in use (expected if you didn't add the build + step). +- *"Cache in use …"* — a pre-built cache is being read (expected after wiring in the build step). + +Use the per-loader **staleness check** to confirm a shipped cache matches what's on disk. + +## Reference + +| 1.x | 2.0 | +| --- | --- | +| PHP 8.2+ | **PHP 8.3+** | +| `get_name(): string` on the post type / taxonomy base classes | Same signature, contract narrowed to `lowercase-string&non-empty-string` (static analysis only) | +| Cache written automatically at runtime (production/staging) | Cache produced at build time only; runtime reads, never writes | +| `should_use_cache()` gates writing | Removed | +| `VIP_GO_APP_ENVIRONMENT` skips caching | No effect (removed) | +| `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` skips the auto-cache | Ignores any shipped cache, always discovers live | +| Cache at `class-loader-cache/discoverer-cache-TenupFramework` | Cache at `class-loader-cache/class-loader-cache-v2.php` | + +## See also +- [Build and Deployment](Build-and-Deployment.md) — generating the cache and CI examples +- [Debugging class loaders](Debugging.md) — verifying cache state on a running site +- [Modules and Initialization](Modules-and-Initialization.md) diff --git a/fixtures/assets/dist/blocks/test-block.asset.php b/fixtures/assets/dist/blocks/test-block.asset.php new file mode 100644 index 0000000..abdc30f --- /dev/null +++ b/fixtures/assets/dist/blocks/test-block.asset.php @@ -0,0 +1 @@ + array( 'test-block-deps' ), 'version' => 'test-block-version'); diff --git a/fixtures/classes/PostTypes/Demo.php b/fixtures/classes/PostTypes/Demo.php index 410cf46..8e5c515 100644 --- a/fixtures/classes/PostTypes/Demo.php +++ b/fixtures/classes/PostTypes/Demo.php @@ -21,7 +21,7 @@ class Demo extends AbstractPostType { * * @return string */ - public function get_name() { + public function get_name(): string { return 'tenup-demo'; } @@ -30,7 +30,7 @@ public function get_name() { * * @return string */ - public function get_singular_label() { + public function get_singular_label(): string { return esc_html__( 'Demo', 'tenup-plugin' ); } @@ -39,7 +39,7 @@ public function get_singular_label() { * * @return string */ - public function get_plural_label() { + public function get_plural_label(): string { return esc_html__( 'Demos', 'tenup-plugin' ); } @@ -52,7 +52,7 @@ public function get_plural_label() { * * @return string */ - public function get_menu_icon() { + public function get_menu_icon(): string { return 'dashicons-chart-pie'; } @@ -61,7 +61,7 @@ public function get_menu_icon() { * * @return bool */ - public function can_register() { + public function can_register(): bool { return true; } @@ -71,7 +71,7 @@ public function can_register() { * * @return array */ - public function get_supported_taxonomies() { + public function get_supported_taxonomies(): array { return [ 'tenup-tax-demo', ]; @@ -82,7 +82,7 @@ public function get_supported_taxonomies() { * * @return void */ - public function after_register() { + public function after_register(): void { // Register any hooks/filters you need. } } diff --git a/fixtures/classes/PostTypes/Page.php b/fixtures/classes/PostTypes/Page.php index fa360d4..7815534 100644 --- a/fixtures/classes/PostTypes/Page.php +++ b/fixtures/classes/PostTypes/Page.php @@ -24,7 +24,7 @@ class Page extends AbstractCorePostType { * * @return string */ - public function get_name() { + public function get_name(): string { return 'page'; } @@ -34,7 +34,7 @@ public function get_name() { * * @return array */ - public function get_supported_taxonomies() { + public function get_supported_taxonomies(): array { return []; } @@ -43,7 +43,7 @@ public function get_supported_taxonomies() { * * @return void */ - public function after_register() { + public function after_register(): void { // Do nothing. } } diff --git a/fixtures/classes/PostTypes/Post.php b/fixtures/classes/PostTypes/Post.php index ce4a28c..21c93ab 100644 --- a/fixtures/classes/PostTypes/Post.php +++ b/fixtures/classes/PostTypes/Post.php @@ -24,7 +24,7 @@ class Post extends AbstractCorePostType { * * @return string */ - public function get_name() { + public function get_name(): string { return 'post'; } @@ -36,7 +36,7 @@ public function get_name() { * * @return array */ - public function get_supported_taxonomies() { + public function get_supported_taxonomies(): array { return []; } @@ -45,7 +45,7 @@ public function get_supported_taxonomies() { * * @return void */ - public function after_register() { + public function after_register(): void { // Do nothing. } } diff --git a/fixtures/classes/Standalone/Standalone.php b/fixtures/classes/Standalone/Standalone.php index d52c2b8..94bfdb7 100644 --- a/fixtures/classes/Standalone/Standalone.php +++ b/fixtures/classes/Standalone/Standalone.php @@ -27,7 +27,7 @@ public function __construct() { * * @return void */ - public function init() { + public function init(): void { echo 'Hello from the Standalone class!'; } } diff --git a/fixtures/classes/Taxonomies/Demo.php b/fixtures/classes/Taxonomies/Demo.php index de86cc6..281dc6b 100644 --- a/fixtures/classes/Taxonomies/Demo.php +++ b/fixtures/classes/Taxonomies/Demo.php @@ -21,7 +21,7 @@ class Demo extends AbstractTaxonomy { * * @return string */ - public function get_name() { + public function get_name(): string { return 'tenup-tax-demo'; } @@ -30,7 +30,7 @@ public function get_name() { * * @return string */ - public function get_singular_label() { + public function get_singular_label(): string { return esc_html__( 'Demo Term', 'tenup-plugin' ); } @@ -39,7 +39,7 @@ public function get_singular_label() { * * @return string */ - public function get_plural_label() { + public function get_plural_label(): string { return esc_html__( 'Demo Terms', 'tenup-plugin' ); } @@ -48,7 +48,7 @@ public function get_plural_label() { * * @return bool */ - public function can_register() { + public function can_register(): bool { return true; } } diff --git a/phpcs.xml b/phpcs.xml index dac99b7..59bfa01 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -17,6 +17,18 @@ + + + */tests/* + + + */tests/* + + diff --git a/phpstan.neon b/phpstan.neon index 4d9795d..98359b9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,7 @@ includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/tomasvotruba/type-coverage/config/extension.neon parameters: paths: @@ -14,5 +15,8 @@ parameters: # Uses func_get_args() - '#^Function apply_filters invoked with [34567] parameters, 2 required\.$#' - '#^Function remove_filter invoked with [34567] parameters, 2-3 required\.$#' - # Remove issues that come from using array as a type rather than string[] or array etc. - - '#no value type specified in iterable type array#' + type_coverage: + return: 99 + param: 99 + property: 99 + constant: 99 diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..fac46ff --- /dev/null +++ b/rector.php @@ -0,0 +1,27 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/fixtures', + ]) + ->withSkip([ + __DIR__ . '/**/node_modules/**', + __DIR__ . '/**/vendor/**', + __DIR__ . '/**/dist/**', + + ]) + ->withPhpSets(php83: true) + ->withTypeCoverageLevel(49) + ->withSkip([ + Rector\Php81\Rector\Array_\FirstClassCallableRector::class, + Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector::class, + Rector\Php70\Rector\StmtsAwareInterface\IfIssetToCoalescingRector::class, + Rector\Php53\Rector\Ternary\TernaryToElvisRector::class, + Rector\Php81\Rector\Property\ReadOnlyPropertyRector::class, + ]); diff --git a/src/Assets/GetAssetInfo.php b/src/Assets/GetAssetInfo.php index 5241f31..ef24266 100644 --- a/src/Assets/GetAssetInfo.php +++ b/src/Assets/GetAssetInfo.php @@ -23,24 +23,22 @@ trait GetAssetInfo { * * @var ?string */ - public $dist_path = null; + public ?string $dist_path = null; /** * Fallback version to use if asset file is not found * * @var ?string */ - public $fallback_version = null; + public ?string $fallback_version = null; /** * Setup asset variables * * @param string $dist_path Path to the dist directory * @param string $fallback_version Fallback version to use if asset file is not found - * - * @return void */ - public function setup_asset_vars( string $dist_path, string $fallback_version ) { + public function setup_asset_vars( string $dist_path, string $fallback_version ): void { $this->dist_path = trailingslashit( $dist_path ); $this->fallback_version = $fallback_version; } @@ -55,13 +53,15 @@ public function setup_asset_vars( string $dist_path, string $fallback_version ) * * @return string|($attribute is null ? array{version: string, dependencies: array} : $attribute is'dependencies' ? array : string) */ - public function get_asset_info( string $slug, ?string $attribute = null ) { + public function get_asset_info( string $slug, ?string $attribute = null ): string|array { if ( is_null( $this->dist_path ) || is_null( $this->fallback_version ) ) { throw new RuntimeException( 'Asset variables not set. Please run setup_asset_vars() before calling get_asset_info().' ); } - if ( file_exists( $this->dist_path . 'js/' . $slug . '.asset.php' ) ) { + if ( file_exists( $this->dist_path . $slug . '.asset.php' ) ) { + $asset = require $this->dist_path . $slug . '.asset.php'; + } elseif ( file_exists( $this->dist_path . 'js/' . $slug . '.asset.php' ) ) { $asset = require $this->dist_path . 'js/' . $slug . '.asset.php'; } elseif ( file_exists( $this->dist_path . 'css/' . $slug . '.asset.php' ) ) { $asset = require $this->dist_path . 'css/' . $slug . '.asset.php'; diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php new file mode 100644 index 0000000..504573e --- /dev/null +++ b/src/BlockRegistrar.php @@ -0,0 +1,330 @@ + + */ + public static array $registered_block_names = []; + + /** + * Static array to track block registration sources for conflict detection. + * + * @var array Block name => source class + */ + public static array $block_sources = []; + + /** + * Whether the allowed_block_types_all filter has been registered. + * + * @var bool + */ + public static bool $filter_registered = false; + + /** + * Get the blocks directory paths. + * + * @return array Array of paths to the blocks directories. + */ + abstract public function get_blocks_directory(): array; + + /** + * Can this module be registered? + * + * @return bool + */ + public function can_register(): bool { + return true; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', [ $this, 'register_blocks' ], 10, 0 ); + } + + /** + * Automatically registers all blocks from the blocks directories. + * + * @return void + */ + public function register_blocks(): void { + // Check if WordPress and block editor are available + if ( ! function_exists( 'register_block_type_from_metadata' ) ) { + error_log( 'BlockRegistrar: WordPress block editor not available' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return; + } + + $blocks_dirs = $this->get_blocks_directory(); + $block_names = []; + $errors = []; + + foreach ( $blocks_dirs as $blocks_dir ) { + // Validate directory path + $validated_dir = $this->validate_directory_path( $blocks_dir ); + if ( ! $validated_dir ) { + $errors[] = "Invalid directory path: {$blocks_dir}"; + continue; + } + + if ( ! file_exists( $validated_dir ) ) { + continue; + } + + // Check if directory is readable + if ( ! is_readable( $validated_dir ) ) { + $errors[] = "Directory not readable: {$validated_dir}"; + continue; + } + + $block_json_files = glob( $validated_dir . '*/block.json' ); + if ( empty( $block_json_files ) ) { + continue; + } + + foreach ( $block_json_files as $filename ) { + $block_folder = dirname( $filename ); + + // Validate block.json file + $block_metadata = $this->validate_block_json( $filename ); + if ( ! $block_metadata ) { + $errors[] = "Invalid block.json: {$filename}"; + continue; + } + + $block_options = $this->get_block_options( $block_folder ); + + /** + * Block registration options with proper typing for WordPress function. + * + * @var array{api_version?: string, title?: string, category?: string|null, parent?: array|null, ancestor?: array|null, allowed_blocks?: array|null, icon?: string|null, description?: string, render_callback?: callable} $block_options + */ + $block = register_block_type_from_metadata( $block_folder, $block_options ); + if ( ! $block ) { + $errors[] = "Failed to register block: {$block_folder}"; + continue; + } + + // Check for block name conflicts + $block_name = $block->name; + $current_class = get_class( $this ); + + if ( isset( self::$block_sources[ $block_name ] ) ) { + $existing_source = self::$block_sources[ $block_name ]; + + // Log the conflict + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + sprintf( + 'BlockRegistrar: Block name conflict detected. Block "%s" already registered by "%s", attempted to register by "%s"', + $block_name, + $existing_source, + $current_class + ) + ); + + // Skip adding to allowed blocks to prevent conflicts + continue; + } + + // Track the block source + self::$block_sources[ $block_name ] = $current_class; + $block_names[] = $block_name; + } + } + + // Log any errors that occurred + if ( ! empty( $errors ) ) { + error_log( 'BlockRegistrar errors: ' . implode( '; ', $errors ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + + if ( ! empty( $block_names ) ) { + $this->register_allowed_block_types( $block_names ); + } + } + + /** + * Get block registration options for a specific block folder. + * + * @param string $block_folder The path to the block folder. + * @return array Block registration options. + */ + protected function get_block_options( string $block_folder ): array { + $block_options = []; + + $markup_file_path = $block_folder . '/markup.php'; + if ( file_exists( $markup_file_path ) ) { + // Only add the render callback if the block has a file called markup.php in its directory + $block_options['render_callback'] = function ( array $attributes, string $content, \WP_Block $block ) use ( $block_folder ): string { + // Create helpful variables that will be accessible in markup.php file + $context = $block->context; + + // Get the actual markup from the markup.php file + ob_start(); + include $block_folder . '/markup.php'; + $output = ob_get_clean(); + return is_string( $output ) ? $output : ''; + }; + } + + return $block_options; + } + + /** + * Register blocks in allowed_block_types_all filter. + * + * @param array $block_names Array of block names to allow. + * @return void + */ + protected function register_allowed_block_types( array $block_names ): void { + // Add new block names to the static registry, avoiding duplicates + foreach ( $block_names as $block_name ) { + if ( ! in_array( $block_name, self::$registered_block_names, true ) ) { + self::$registered_block_names[] = $block_name; + } + } + + // Only register the filter once, regardless of how many instances exist + if ( ! self::$filter_registered ) { + add_filter( + 'allowed_block_types_all', + [ self::class, 'filter_allowed_block_types' ] + ); + self::$filter_registered = true; + } + } + + /** + * Static callback for the allowed_block_types_all filter. + * + * @param array|bool $allowed_blocks Current allowed blocks. + * @return array|bool Modified allowed blocks. + */ + public static function filter_allowed_block_types( array|bool $allowed_blocks ): array|bool { + if ( ! is_array( $allowed_blocks ) ) { + return $allowed_blocks; + } + + return array_merge( $allowed_blocks, self::$registered_block_names ); + } + + /** + * Check if a block name has a conflict. + * + * @param string $block_name The block name to check. + * @return bool True if there's a conflict, false otherwise. + */ + public static function has_block_conflict( string $block_name ): bool { + return isset( self::$block_sources[ $block_name ] ); + } + + /** + * Get the source class for a registered block. + * + * @param string $block_name The block name. + * @return string|null The source class name or null if not found. + */ + public static function get_block_source( string $block_name ): ?string { + return self::$block_sources[ $block_name ] ?? null; + } + + /** + * Get all registered block names and their sources. + * + * @return array Block name => source class. + */ + public static function get_all_block_sources(): array { + return self::$block_sources; + } + + /** + * Validate directory path for security and correctness. + * + * @param string $path The directory path to validate. + * @return string|false Validated path or false if invalid. + */ + protected function validate_directory_path( string $path ): string|false { + // Check for empty or null paths + if ( empty( $path ) ) { + return false; + } + + // Check for directory traversal attacks + if ( str_contains( $path, '..' ) || str_contains( $path, './' ) ) { + return false; + } + + // Normalize path separators + $path = str_replace( '\\', '/', $path ); + + // Ensure path ends with directory separator + if ( ! str_ends_with( $path, '/' ) ) { + $path .= '/'; + } + + // Check for reasonable path length (prevent excessive memory usage) + if ( strlen( $path ) > 1000 ) { + return false; + } + + return $path; + } + + /** + * Validate block.json file and return metadata. + * + * @param string $file_path Path to block.json file. + * @return array|false Block metadata or false if invalid. + */ + protected function validate_block_json( string $file_path ): array|false { + // Check if file exists and is readable + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return false; + } + + // Read and decode JSON + // This approach avoids file_get_contents() which is a PHPCS issue + ob_start(); + include $file_path; + $json_content = ob_get_clean(); + + if ( empty( $json_content ) ) { + return false; + } + + $metadata = json_decode( $json_content, true ); + if ( json_last_error() !== JSON_ERROR_NONE ) { + return false; + } + + // Validate required fields + if ( ! is_array( $metadata ) || ! isset( $metadata['name'] ) || ! is_string( $metadata['name'] ) ) { + return false; + } + + // Validate block name format (namespace/name) + if ( ! preg_match( '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/', $metadata['name'] ) ) { + return false; + } + + return $metadata; + } +} diff --git a/src/Cache/ReadOnlyFileDiscoverCacheDriver.php b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php new file mode 100644 index 0000000..8757406 --- /dev/null +++ b/src/Cache/ReadOnlyFileDiscoverCacheDriver.php @@ -0,0 +1,65 @@ +directory = rtrim( $directory, '/' ); + $this->serialize = $serialize; + $this->filename = $filename; + } + + /** + * No-op. Cache generation happens at build time only, never at runtime. + * + * @param string $id The cache identifier. + * @param array $discovered The discovered structures. + * + * @return void + */ + public function put( string $id, array $discovered ): void { + // Intentionally empty. + } + + /** + * No-op. The runtime never deletes the cache. + * + * @param string $id The cache identifier. + * + * @return void + */ + public function forget( string $id ): void { + // Intentionally empty. + } +} diff --git a/src/Core/Emoji.php b/src/Core/Emoji.php new file mode 100644 index 0000000..f6f7d32 --- /dev/null +++ b/src/Core/Emoji.php @@ -0,0 +1,112 @@ + $plugins An array of default TinyMCE plugins. + * @return array An array of TinyMCE plugins, without wpemoji. + */ + public function disable_emojis_tinymce( array $plugins ): array { + if ( in_array( 'wpemoji', $plugins, true ) ) { + return array_diff( $plugins, [ 'wpemoji' ] ); + } + + return $plugins; + } + + /** + * Remove emoji CDN hostname from DNS prefetching hints. + * + * @link https://developer.wordpress.org/reference/hooks/emoji_svg_url/ + * + * Typed for the common case of a plain list of URL strings. The `wp_resource_hints` + * contract also permits an entry to be an array of link attributes; array_diff() below + * would coerce such an entry to string and warn. No core or common plugin path produces + * one before this callback runs, so the narrower type documents the assumption rather + * than widening every caller for a case that does not occur in practice. + * + * @param array $urls URLs to print for resource hints. + * @param string $relation_type The relation type the URLs are printed for. + * @return array Difference between the two arrays. + */ + public function disable_emoji_dns_prefetch( array $urls, string $relation_type ): array { + if ( 'dns-prefetch' === $relation_type ) { + /** This filter is documented in wp-includes/formatting.php */ + $emoji_svg_url = apply_filters( 'emoji_svg_url', 'https://s.w.org/images/core/emoji/2/svg/' ); + + // The filter can return anything. Only a string names a URL to remove; for any + // other value there is nothing to take out of the hints, so leave them untouched. + if ( is_string( $emoji_svg_url ) ) { + $urls = array_values( array_diff( $urls, [ $emoji_svg_url ] ) ); + } + } + + return $urls; + } +} diff --git a/src/Core/HeadOverrides.php b/src/Core/HeadOverrides.php new file mode 100644 index 0000000..b5c71b7 --- /dev/null +++ b/src/Core/HeadOverrides.php @@ -0,0 +1,55 @@ +> + */ + protected static array $loaders = []; + + /** + * Whether this copy has wired its WordPress hooks yet. + * + * @var bool + */ + protected static bool $booted = false; + + /** + * Record a loader and ensure the admin hooks are wired. + * + * Called from ModuleInitialization::init_classes() (admin requests only). Returns early + * without storing anything or adding hooks when the tooling is disabled. + * + * @param array $record The loader record. + * + * @return void + */ + public static function record( array $record ): void { + if ( ! self::is_enabled() ) { + return; + } + + // Keep one record per directory: if init_classes() runs more than once for the same + // directory in a request, the latest call (with fresh timing) replaces the earlier one + // rather than producing a duplicate card. + $directory = isset( $record['directory'] ) && is_string( $record['directory'] ) ? $record['directory'] : ''; + foreach ( self::$loaders as $index => $existing ) { + if ( ( $existing['directory'] ?? null ) === $directory ) { + self::$loaders[ $index ] = $record; + self::boot(); + return; + } + } + + self::$loaders[] = $record; + + self::boot(); + } + + /** + * The loader records collected for this framework copy (not the cross-copy aggregate — + * use the `tenup_framework_debug_loaders` filter for that). + * + * @return array> + */ + public static function get_loaders(): array { + return self::$loaders; + } + + /** + * Whether the debug tooling is enabled. + * + * Off when WordPress isn't loaded, when the disable constant is set, or when a filter turns + * it off. On by default (in the admin, which is the only place record() is called). + * + * @return bool + */ + public static function is_enabled(): bool { + if ( ! function_exists( 'add_action' ) ) { + return false; + } + + if ( defined( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) && true === constant( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG' ) ) { + return false; + } + + return (bool) apply_filters( 'tenup_framework_enable_loader_debug', true ); + } + + /** + * Wire the WordPress hooks once per copy: contribute this copy's records to the shared + * filter, and register the admin page a single time across every loaded copy. + * + * @return void + */ + protected static function boot(): void { + if ( self::$booted ) { + return; + } + self::$booted = true; + + add_filter( + self::FILTER, + static function ( mixed $loaders ) { + return array_merge( (array) $loaders, self::$loaders ); + } + ); + + // Register the page only once, even when several framework copies are loaded. + if ( empty( $GLOBALS['tenup_framework_debug_page_registered'] ) ) { + $GLOBALS['tenup_framework_debug_page_registered'] = true; + add_action( 'admin_menu', [ self::class, 'register_page' ] ); + } + } + + /** + * Register the hidden admin page. + * + * An empty parent slug keeps the page out of every menu while leaving it reachable at + * admin.php?page=tenup-framework-loaders. + * + * @return void + */ + public static function register_page(): void { + $title = __( 'WP Framework Loaders', 'tenup-framework' ); + + add_submenu_page( + '', + $title, + $title, + self::CAPABILITY, + self::PAGE_SLUG, + [ self::class, 'render_page' ] + ); + } + + /** + * Render the page. + * + * @return void + */ + public static function render_page(): void { + if ( ! current_user_can( self::CAPABILITY ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'tenup-framework' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only diagnostic; the value is nonce-verified below before use. + $requested_check = ( isset( $_GET['check'] ) && is_string( $_GET['check'] ) ) ? sanitize_text_field( wp_unslash( $_GET['check'] ) ) : ''; + $check = self::check_is_valid( $requested_check ) ? $requested_check : ''; + + $loaders = apply_filters( self::FILTER, [] ); + if ( ! is_array( $loaders ) ) { + $loaders = []; + } + + echo '
'; + self::render_styles(); + echo '

' . esc_html__( 'WP Framework Loaders', 'tenup-framework' ) . '

'; + echo '

' . esc_html__( 'Each card is a directory passed to ModuleInitialization::init_classes(), with the state of its class-loader cache. Caches are built at deploy time and read — never written — at runtime.', 'tenup-framework' ) . '

'; + + if ( empty( $loaders ) ) { + echo '
' . esc_html__( 'No class loaders were recorded for this request.', 'tenup-framework' ) . '
'; + echo '
'; + return; + } + + foreach ( $loaders as $loader ) { + if ( is_array( $loader ) ) { + self::render_loader( $loader, $check ); + } + } + + echo ''; + } + + /** + * Render a single loader block. + * + * @param array $loader The loader record. + * @param string $check The validated staleness-check token, if any. + * + * @return void + */ + protected static function render_loader( array $loader, string $check ): void { + $directory = self::to_string( $loader['directory'] ?? '' ); + $cache_file = self::to_string( $loader['cache_file'] ?? '' ); + $classes = isset( $loader['classes'] ) && is_array( $loader['classes'] ) + ? array_values( array_map( [ self::class, 'to_string' ], $loader['classes'] ) ) + : []; + + $state = self::cache_state( $loader ); + $legacy = self::legacy_files( $cache_file ); + + echo '
'; + + echo '
'; + echo '

' . esc_html( self::owner_label( $directory ) ) . '

'; + echo '' . esc_html( $state['badge'] ) . ''; + echo '
'; + + if ( '' !== $state['note'] ) { + echo '
' . esc_html( $state['note'] ) . '
'; + } + + if ( ! empty( $legacy ) ) { + echo '
' . esc_html( + sprintf( + /* translators: %s: comma-separated list of unexpected filenames. */ + __( 'Unexpected files in the cache directory, likely left by an older version: %s. Delete them or redeploy.', 'tenup-framework' ), + implode( ', ', $legacy ) + ) + ) . '
'; + } + + echo ''; + self::render_row( __( 'Directory', 'tenup-framework' ), $directory ); + self::render_row( __( 'Framework version', 'tenup-framework' ), self::version_label( $loader ) ); + self::render_row( __( 'Cache file', 'tenup-framework' ), '' !== $cache_file ? $cache_file : '—' ); + self::render_row( __( 'Cache detail', 'tenup-framework' ), self::cache_detail( $loader ) ); + self::render_row( __( 'Discovery time', 'tenup-framework' ), self::format_duration( $loader['discovery_seconds'] ?? null ) ); + self::render_row( __( 'Class lookup time', 'tenup-framework' ), self::format_duration( $loader['lookup_seconds'] ?? null ) ); + echo '
'; + + echo '
'; + echo '' . esc_html( + sprintf( + /* translators: %d: number of classes. */ + _n( '%d class loaded', '%d classes loaded', count( $classes ), 'tenup-framework' ), + count( $classes ) + ) + ) . ''; + self::render_classes( $classes ); + echo '
'; + + self::render_staleness( $directory, $classes, $check ); + + echo '
'; + } + + /** + * Render a label/value row, escaping both. + * + * @param string $label The row label. + * @param string $value The row value. + * + * @return void + */ + protected static function render_row( string $label, string $value ): void { + echo '' . esc_html( $label ) . '' . esc_html( $value ) . ''; + } + + /** + * Render the list of loaded classes with the file each resolves to. + * + * @param array $classes The class names. + * + * @return void + */ + protected static function render_classes( array $classes ): void { + if ( empty( $classes ) ) { + return; + } + + echo ''; + echo ''; + + $module_init = ModuleInitialization::instance(); + + foreach ( $classes as $class ) { + $reflection = $module_init->get_fully_loadable_class( $class ); + $file = $reflection ? (string) $reflection->getFileName() : __( 'Does not resolve — likely a stale cache entry.', 'tenup-framework' ); + + echo ''; + } + + echo '
' . esc_html__( 'Class', 'tenup-framework' ) . '' . esc_html__( 'File', 'tenup-framework' ) . '
' . esc_html( $class ) . '' . esc_html( $file ) . '
'; + } + + /** + * Render the on-demand staleness check: a trigger link, and the diff when requested. + * + * @param string $directory The loader directory. + * @param array $classes The currently loaded class list. + * @param string $check The validated staleness-check token, if any. + * + * @return void + */ + protected static function render_staleness( string $directory, array $classes, string $check ): void { + if ( '' === $directory ) { + return; + } + + if ( self::token_for( $directory ) !== $check ) { + $url = wp_nonce_url( + add_query_arg( + [ + 'page' => self::PAGE_SLUG, + 'check' => self::token_for( $directory ), + ], + admin_url( 'admin.php' ) + ), + self::CHECK_NONCE + ); + + echo '

' . esc_html__( 'Check this cache for staleness', 'tenup-framework' ) . '

'; + return; + } + + $live_start = hrtime( true ); + $live = ModuleInitialization::instance()->discover_live( $directory ); + $live_seconds = ( hrtime( true ) - $live_start ) / 1e9; + + $loaded = array_values( $classes ); + $removed = array_diff( $loaded, $live ); // In cache but no longer on disk. + $added = array_diff( $live, $loaded ); // On disk but missing from the cache. + + $timing = sprintf( + /* translators: %s: formatted duration. */ + __( 'Live discovery took %s.', 'tenup-framework' ), + self::format_duration( $live_seconds ) + ); + + if ( empty( $removed ) && empty( $added ) ) { + echo '
' . esc_html__( 'Up to date — the cache matches a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ) . '
'; + return; + } + + echo '
'; + echo '' . esc_html__( 'Stale — the cache differs from a live scan.', 'tenup-framework' ) . ' ' . esc_html( $timing ); + + if ( ! empty( $added ) ) { + echo '

' . esc_html__( 'On disk but missing from the cache:', 'tenup-framework' ) . '

    '; + foreach ( $added as $class ) { + echo '
  • ' . esc_html( $class ) . '
  • '; + } + echo '
'; + } + + if ( ! empty( $removed ) ) { + echo '

' . esc_html__( 'In the cache but no longer on disk:', 'tenup-framework' ) . '

    '; + foreach ( $removed as $class ) { + echo '
  • ' . esc_html( $class ) . '
  • '; + } + echo '
'; + } + + echo '

' . esc_html__( 'Regenerate the cache in your build (composer generate-class-cache) or remove the file and redeploy.', 'tenup-framework' ) . '

'; + echo '
'; + } + + /** + * Coerce a mixed value (loader records arrive through a filter as mixed) to a string, + * returning an empty string for anything non-scalar. + * + * @param mixed $value The value to coerce. + * + * @return string + */ + protected static function to_string( mixed $value ): string { + return is_scalar( $value ) ? (string) $value : ''; + } + + /** + * Format a duration in seconds for display, choosing a sensible unit. Values arrive through + * a filter as mixed, so anything non-numeric or non-positive renders as a placeholder. + * + * @param mixed $seconds The duration in seconds. + * + * @return string + */ + protected static function format_duration( mixed $seconds ): string { + $seconds = is_numeric( $seconds ) ? (float) $seconds : 0.0; + + // Values arrive through the cross-copy filter as mixed, so reject non-positive and + // non-finite (INF/NAN) input rather than rendering "inf s" / "nan s". + if ( $seconds <= 0.0 || ! is_finite( $seconds ) ) { + return '—'; + } + + $milliseconds = $seconds * 1000; + + if ( $milliseconds < 1 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 3 ) ); + } + + if ( $milliseconds < 1000 ) { + /* translators: %s: duration in milliseconds. */ + return sprintf( __( '%s ms', 'tenup-framework' ), number_format( $milliseconds, 2 ) ); + } + + /* translators: %s: duration in seconds. */ + return sprintf( __( '%s s', 'tenup-framework' ), number_format( $seconds, 2 ) ); + } + + /** + * A stable, opaque token identifying a loader directory in the check link. + * + * @param string $directory The loader directory. + * + * @return string + */ + protected static function token_for( string $directory ): string { + return md5( $directory ); + } + + /** + * Whether a requested check token is well-formed and the request is nonce-verified. + * + * @param string $token The requested token. + * + * @return bool + */ + protected static function check_is_valid( string $token ): bool { + if ( '' === $token ) { + return false; + } + + $nonce = ( isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + + return (bool) wp_verify_nonce( $nonce, self::CHECK_NONCE ); + } + + /** + * A human-friendly owner label for a directory (plugin/theme name where derivable). + * + * @param string $directory The loader directory. + * + * @return string + */ + protected static function owner_label( string $directory ): string { + if ( '' === $directory ) { + return __( 'Unknown loader', 'tenup-framework' ); + } + + $roots = []; + if ( defined( 'WP_PLUGIN_DIR' ) ) { + $roots[] = [ self::to_string( constant( 'WP_PLUGIN_DIR' ) ), __( 'Plugin', 'tenup-framework' ) ]; + } + if ( defined( 'WPMU_PLUGIN_DIR' ) ) { + $roots[] = [ self::to_string( constant( 'WPMU_PLUGIN_DIR' ) ), __( 'Must-use plugin', 'tenup-framework' ) ]; + } + if ( function_exists( 'get_theme_root' ) ) { + $roots[] = [ self::to_string( get_theme_root() ), __( 'Theme', 'tenup-framework' ) ]; + } + + foreach ( $roots as $candidate ) { + $root = rtrim( $candidate[0], '/' ); + $type = $candidate[1]; + if ( '' !== $root && str_starts_with( $directory, $root . '/' ) ) { + $relative = ltrim( substr( $directory, strlen( $root ) ), '/' ); + $segment = explode( '/', $relative )[0]; + + /* translators: 1: owner type (Plugin/Theme), 2: plugin or theme folder name. */ + return sprintf( __( '%1$s: %2$s', 'tenup-framework' ), $type, $segment ); + } + } + + return $directory; + } + + /** + * A label describing the framework version that recorded a loader. + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function version_label( array $loader ): string { + $version = self::to_string( $loader['version'] ?? '' ); + $reference = self::to_string( $loader['reference'] ?? '' ); + + if ( '' === $version ) { + $version = __( 'unknown', 'tenup-framework' ); + } + + if ( '' !== $reference ) { + $version .= ' (' . substr( $reference, 0, 8 ) . ')'; + } + + return $version; + } + + /** + * Resolve the headline cache state for a loader: a severity, a short badge label, and an + * optional explanatory note. + * + * Severity maps to the badge/notice colour. Note that running uncached is a valid default + * (caching is opt-in), so it is surfaced as a warning to be noticeable, not as an error. + * + * @param array $loader The loader record. + * + * @return array{severity: string, badge: string, note: string} + */ + protected static function cache_state( array $loader ): array { + if ( ! empty( $loader['cache_disabled'] ) ) { + return [ + 'severity' => 'warn', + 'badge' => __( 'Caching disabled', 'tenup-framework' ), + 'note' => __( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is set, so any shipped cache is ignored and classes are discovered live on every request.', 'tenup-framework' ), + ]; + } + + if ( ! empty( $loader['cache_failed'] ) ) { + return [ + 'severity' => 'error', + 'badge' => __( 'Cache failed to load — running live', 'tenup-framework' ), + 'note' => __( 'A cache file is present but could not be read (corrupt or truncated), so the framework fell back to a live scan on every request. Rebuild the cache in your pipeline and redeploy.', 'tenup-framework' ), + ]; + } + + if ( empty( $loader['cache_exists'] ) ) { + return [ + 'severity' => 'warn', + 'badge' => __( 'Uncached — live discovery', 'tenup-framework' ), + 'note' => __( 'No cache file is present, so classes are discovered live on every request. That is the correct default for small projects; for large codebases, build a cache in your pipeline (see Build and Deployment).', 'tenup-framework' ), + ]; + } + + if ( empty( $loader['cache_used'] ) ) { + return [ + 'severity' => 'error', + 'badge' => __( 'Cache present but not used', 'tenup-framework' ), + 'note' => __( 'A cache file exists but is not being used. This is unexpected — check TENUP_FRAMEWORK_DISABLE_CLASS_CACHE.', 'tenup-framework' ), + ]; + } + + return [ + 'severity' => 'ok', + 'badge' => __( 'Cache in use', 'tenup-framework' ), + 'note' => '', + ]; + } + + /** + * A short description of the cache file on disk — relative age, size, and the absolute build + * time in UTC — or a placeholder when none. Format: "Built ago · ()". + * + * @param array $loader The loader record. + * + * @return string + */ + protected static function cache_detail( array $loader ): string { + $cache_file = self::to_string( $loader['cache_file'] ?? '' ); + + if ( '' === $cache_file || ! file_exists( $cache_file ) ) { + return __( 'No cache file on disk.', 'tenup-framework' ); + } + + $mtime = (int) filemtime( $cache_file ); + $size = (int) filesize( $cache_file ); + + if ( ! $mtime ) { + return sprintf( + /* translators: %s: file size. */ + __( 'Built at an unknown time · %s', 'tenup-framework' ), + size_format( $size ) + ); + } + + return sprintf( + /* translators: 1: relative age (e.g. "5 minutes"); 2: file size; 3: absolute build time in UTC. */ + __( 'Built %1$s ago · %2$s · %3$s', 'tenup-framework' ), + human_time_diff( $mtime ), + size_format( $size ), + gmdate( 'Y-m-d H:i:s', $mtime ) . ' UTC' + ); + } + + /** + * Output the page's scoped styles once. + * + * @return void + */ + protected static function render_styles(): void { + echo ''; + } + + /** + * Find files in the cache directory that are not the current cache file — usually stale + * leftovers from an older framework version. + * + * @param string $cache_file The current cache file path. + * + * @return array The unexpected filenames. + */ + protected static function legacy_files( string $cache_file ): array { + if ( '' === $cache_file ) { + return []; + } + + $dir = dirname( $cache_file ); + if ( ! is_dir( $dir ) ) { + return []; + } + + $expected = basename( $cache_file ); + $found = []; + + foreach ( new \DirectoryIterator( $dir ) as $item ) { + if ( $item->isDot() || $item->isDir() ) { + continue; + } + $name = $item->getFilename(); + if ( $name !== $expected ) { + $found[] = $name; + } + } + + return $found; + } +} diff --git a/src/Module.php b/src/Module.php index fc67ebf..a4fc4c7 100644 --- a/src/Module.php +++ b/src/Module.php @@ -20,24 +20,18 @@ trait Module { * Lower number will be initialized first. * * @note This has no correlation to the `init` priority. It's just a way to allow certain classes to be initialized before others. - * - * @return int The priority of the module. */ - public function load_order() { + public function load_order(): int { return 10; } /** * Checks whether the Module should run within the current context. - * - * @return bool */ - abstract public function can_register(); + abstract public function can_register(): bool; /** * Connects the Module with WordPress using Hooks and/or Filters. - * - * @return void */ - abstract public function register(); + abstract public function register(): void; } diff --git a/src/ModuleInitialization.php b/src/ModuleInitialization.php index 9f9a8e3..c218554 100644 --- a/src/ModuleInitialization.php +++ b/src/ModuleInitialization.php @@ -9,10 +9,12 @@ namespace TenupFramework; +use Composer\InstalledVersions; use ReflectionClass; use Spatie\StructureDiscoverer\Cache\FileDiscoverCacheDriver; -use Spatie\StructureDiscoverer\Data\DiscoveredStructure; use Spatie\StructureDiscoverer\Discover; +use TenupFramework\Cache\ReadOnlyFileDiscoverCacheDriver; +use TenupFramework\Debug\LoaderDebug; /** * ModuleInitialization class. @@ -21,19 +23,37 @@ */ class ModuleInitialization { + /** + * The directory name, within the discovery directory, that holds the class cache. + */ + public const string CACHE_DIR_NAME = 'class-loader-cache'; + + /** + * The class cache filename. + * + * Bumping this value invalidates caches written by older framework versions: the + * runtime looks for a filename the previous build never produced, so a stale file + * is simply ignored until a fresh build regenerates it. The old file is harmless + * cruft that a clean deploy clears. + */ + public const string CACHE_FILENAME = 'class-loader-cache-v2.php'; + + /** + * The Spatie cache identifier. + */ + public const string CACHE_ID = 'TenupFramework'; + /** * The class instance. * - * @var null|ModuleInitialization + * @var ?\TenupFramework\ModuleInitialization */ - private static $instance = null; + private static ?\TenupFramework\ModuleInitialization $instance = null; /** * Get the instance of the class. - * - * @return ModuleInitialization */ - public static function instance() { + public static function instance(): \TenupFramework\ModuleInitialization { if ( null === self::$instance ) { self::$instance = new self(); } @@ -52,7 +72,16 @@ private function __construct() { * * @var array */ - protected $classes = []; + protected array $classes = []; + + /** + * Whether the most recent get_classes() call fell back to a live scan because a shipped + * cache file failed to load (corrupt or truncated). Read by record_loader_debug() so the + * debug page flags the degraded state instead of reporting the cache as healthy. + * + * @var bool + */ + protected bool $cache_read_failed = false; /** * Get all the TenupFramework plugin classes. @@ -61,61 +90,240 @@ private function __construct() { * * @return array */ - public function get_classes( $dir ) { + public function get_classes( string $dir ): array { $this->directory_check( $dir ); + $class_finder = $this->build_discoverer( $dir ); + + // The runtime only ever reads a pre-built cache; it never writes one. Caching is + // therefore opt-in: with no cache file present we discover live on every request, + // which is the correct default. A cache is produced at build time via the + // `tenup-framework-generate-class-cache` command and shipped as a build artefact. + // + // Define TENUP_FRAMEWORK_DISABLE_CLASS_CACHE to ignore any shipped cache and always + // discover live (useful for debugging). + if ( ! $this->cache_disabled() ) { + $class_finder->withCache( + self::CACHE_ID, + new ReadOnlyFileDiscoverCacheDriver( + $this->get_cache_directory( $dir ), + false, + self::CACHE_FILENAME + ) + ); + } + + $this->cache_read_failed = false; + + try { + // array_filter is inside the try so that a cache which parses but returns a + // non-array (not only a truncated one) also falls back rather than fataling here. + return array_filter( $class_finder->get(), fn( mixed $cl ) => is_string( $cl ) ); + } catch ( \Throwable $e ) { + // A shipped cache file that is corrupt or truncated — a partial deploy, an + // interrupted build, a half-written rsync — would otherwise fatal on every request + // (the cache is executable PHP loaded with `require`). Fall back to a fresh live + // discovery so the site keeps working, uncached, until the cache is rebuilt. This + // is the same spirit as issue #30: a bad cache must never take the site down. + $this->cache_read_failed = true; + + if ( function_exists( 'do_action' ) ) { + /** + * Fires when a shipped class cache could not be read and the runtime fell back + * to a live scan. Lets a project log or alert on a degraded (uncached) deploy; + * the loader debug page flags the same state. + * + * @param string $dir The directory whose cache failed to load. + * @param \Throwable $e The error raised while reading the cache. + */ + do_action( 'tenup_framework_cache_load_failed', $dir, $e ); + } + + return array_filter( $this->build_discoverer( $dir )->get(), fn( mixed $cl ) => is_string( $cl ) ); + } + } + + /** + * Generate the class cache for a directory and write it to disk. + * + * This is the build-time counterpart to get_classes(): it is the only place the + * framework writes the cache, and it deliberately makes no WordPress calls so it can + * run from a plain CLI script during CI without bootstrapping WordPress. The resulting + * file is then deployed as a build artefact and read (never rewritten) at runtime. + * + * @param string|null $dir The directory to search for classes. + * + * @return array The discovered class names that were cached. + */ + public function generate_cache( ?string $dir = '' ): array { + $this->directory_check( $dir ); + + $class_finder = $this->build_discoverer( $dir ); + + $class_finder->withCache( + self::CACHE_ID, + new FileDiscoverCacheDriver( + $this->get_cache_directory( $dir ), + false, + self::CACHE_FILENAME + ) + ); + + // cache() forces a fresh discovery and overwrites any existing cache file, so a + // regenerate always reflects the current code rather than a previous build. + $classes = $class_finder->cache(); + + return array_filter( $classes, fn( mixed $cl ) => is_string( $cl ) ); + } + + /** + * Build a discoverer configured the same way for both reading and generating, so the + * two paths can never drift apart. + * + * @param string $dir The directory to search for classes. + * + * @return Discover + */ + protected function build_discoverer( string $dir ): Discover { // Get all classes from this directory and its subdirectories. $class_finder = Discover::in( $dir ); // Only fetch classes. $class_finder->classes(); - // Disable inheritance chain resolution + // Disable inheritance chain resolution. $class_finder->withoutChains(); - // If we are in production or staging, cache the class loader to improve performance. - if ( $this->should_use_cache() ) { - $class_finder->withCache( - __NAMESPACE__, - new FileDiscoverCacheDriver( $dir . '/class-loader-cache' ) - ); - } - - $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) ); + return $class_finder; + } - // Return the classes - return $classes; + /** + * Get the absolute path to the cache directory for a discovery directory. + * + * @param string $dir The directory to search for classes. + * + * @return string + */ + protected function get_cache_directory( string $dir ): string { + return rtrim( $dir, '/' ) . '/' . self::CACHE_DIR_NAME; } /** - * Should we set up and use the class cache? + * Whether class caching has been explicitly disabled. + * + * When true, the runtime ignores any shipped cache and discovers classes live on every + * request. Useful for debugging a suspected stale or incorrect cache. * * @return bool */ - protected function should_use_cache(): bool { - if ( defined( 'VIP_GO_APP_ENVIRONMENT' ) ) { - return false; + protected function cache_disabled(): bool { + return defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE; + } + + /** + * Discover the classes in a directory live, ignoring any cache. + * + * Used by the admin-only debug page's on-demand staleness check to compare what is actually + * on disk against what the cache loaded. + * + * @param string|null $dir The directory to search for classes. + * + * @return array + */ + public function discover_live( ?string $dir ): array { + $this->directory_check( $dir ); + + return array_values( array_filter( $this->build_discoverer( $dir )->get(), fn( mixed $cl ) => is_string( $cl ) ) ); + } + + /** + * Hand loader metadata to the admin-only debug tooling. + * + * Front-end requests do nothing here: the data is only viewable in the admin, so it is only + * gathered there. The is_admin() check happens before LoaderDebug is referenced, so that + * class never autoloads on the front end. + * + * @param string $dir The directory that was discovered. + * @param array $classes The discovered class names. + * @param float $discovery_seconds Seconds spent obtaining the class list (cache read or live scan). + * @param float $lookup_seconds Seconds spent reflecting, instantiating and registering the classes. + * + * @return void + */ + protected function record_loader_debug( string $dir, array $classes, float $discovery_seconds = 0.0, float $lookup_seconds = 0.0 ): void { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; } - if ( ! in_array( wp_get_environment_type(), [ 'production', 'staging' ], true ) ) { - return false; + // is_admin() is also true for admin-ajax.php. The debug page is a normal admin GET that + // re-runs discovery and records afresh, so recording on ajax requests is pure waste + // (often triggered from the front end). Skip them. + if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { + return; } - if ( defined( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE' ) && true === TENUP_FRAMEWORK_DISABLE_CLASS_CACHE ) { - return false; + $cache_file = $this->get_cache_directory( $dir ) . '/' . self::CACHE_FILENAME; + $cache_exists = file_exists( $cache_file ); + $disabled = $this->cache_disabled(); + $failed = $this->cache_read_failed; + + LoaderDebug::record( + [ + 'directory' => $dir, + 'cache_file' => $cache_file, + 'cache_exists' => $cache_exists, + // A present cache that failed to load was not actually used — the runtime fell + // back to a live scan — so report it as such rather than "in use". + 'cache_used' => $cache_exists && ! $disabled && ! $failed, + 'cache_disabled' => $disabled, + 'cache_failed' => $failed, + 'classes' => $classes, + 'version' => $this->framework_version(), + 'reference' => $this->framework_reference(), + 'discovery_seconds' => $discovery_seconds, + 'lookup_seconds' => $lookup_seconds, + ] + ); + } + + /** + * The installed framework version, or an empty string when it cannot be determined. + * + * @return string + */ + protected function framework_version(): string { + if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) { + return (string) InstalledVersions::getPrettyVersion( '10up/wp-framework' ); } - return true; + return ''; + } + + /** + * The installed framework reference (git hash), or an empty string when unavailable. + * + * @return string + */ + protected function framework_reference(): string { + if ( class_exists( InstalledVersions::class ) && InstalledVersions::isInstalled( '10up/wp-framework' ) ) { + return (string) InstalledVersions::getReference( '10up/wp-framework' ); + } + + return ''; } /** * Check if the directory exists. * - * @param string $dir The directory to check. + * Returning at all proves $dir is a usable path: every other outcome throws. The assert + * hands that guarantee to PHPStan so callers taking a nullable $dir do not each need their + * own redundant null check. * - * @throws \RuntimeException If the directory does not exist. + * @param string|null $dir The directory to check. * - * @return bool + * @phpstan-assert non-empty-string $dir + * + * @throws \RuntimeException If the directory does not exist. */ - protected function directory_check( $dir ): bool { + protected function directory_check( ?string $dir ): bool { if ( empty( $dir ) ) { throw new \RuntimeException( 'Directory is required to initialize classes.' ); } @@ -132,14 +340,22 @@ protected function directory_check( $dir ): bool { * Initialize all the TenupFramework plugin classes. * * @param string $dir The directory to search for classes. - * - * @return void */ - public function init_classes( $dir = '' ) { + public function init_classes( ?string $dir = '' ): void { $this->directory_check( $dir ); + // Time discovery (a cache read when a cache is present, a live filesystem scan + // otherwise) separately from the reflection/instantiation work below, so the debug + // page can show where the request's time actually goes. hrtime() is monotonic, so an + // NTP adjustment mid-request cannot produce a negative or wildly wrong delta. + $discovery_start = hrtime( true ); + $classes = $this->get_classes( $dir ); + $discovery_seconds = ( hrtime( true ) - $discovery_start ) / 1e9; + + $lookup_start = hrtime( true ); + $load_class_order = []; - foreach ( $this->get_classes( $dir ) as $class ) { + foreach ( $classes as $class ) { // Create a slug for the class name. $slug = $this->slugify_class_name( $class ); @@ -161,7 +377,7 @@ public function init_classes( $dir = '' ) { } // Check if the class implements ModuleInterface before instantiating it - if ( ! $reflection_class->implementsInterface( 'TenupFramework\ModuleInterface' ) ) { + if ( ! $reflection_class->implementsInterface( \TenupFramework\ModuleInterface::class ) ) { continue; } @@ -197,6 +413,10 @@ public function init_classes( $dir = '' ) { } } } + + $lookup_seconds = ( hrtime( true ) - $lookup_start ) / 1e9; + + $this->record_loader_debug( $dir, $classes, $discovery_seconds, $lookup_seconds ); } /** @@ -204,8 +424,6 @@ public function init_classes( $dir = '' ) { * * @param string $class_name The name of the class to load. * - * @return false|ReflectionClass Returns a ReflectionClass instance if the class is loadable, or false if it is not. - * * @phpstan-ignore missingType.generics */ public function get_fully_loadable_class( string $class_name ): false|ReflectionClass { @@ -213,7 +431,7 @@ public function get_fully_loadable_class( string $class_name ): false|Reflection // Create a new reflection of the class. // @phpstan-ignore argument.type return new ReflectionClass( $class_name ); - } catch ( \Throwable $e ) { + } catch ( \Throwable ) { // This includes ReflectionException, Error due to missing parent, etc. return false; } @@ -223,10 +441,8 @@ public function get_fully_loadable_class( string $class_name ): false|Reflection * Slugify a class name. * * @param string $class_name The class name. - * - * @return string */ - protected function slugify_class_name( $class_name ) { + protected function slugify_class_name( string $class_name ): string { return sanitize_title( str_replace( '\\', '-', $class_name ) ); } @@ -234,10 +450,8 @@ protected function slugify_class_name( $class_name ) { * Get a class by its full class name, including namespace. * * @param string $class_name The class name & namespace. - * - * @return false|ModuleInterface */ - public function get_class( $class_name ) { + public function get_class( string $class_name ): false|ModuleInterface { $class_name = $this->slugify_class_name( $class_name ); if ( isset( $this->classes[ $class_name ] ) ) { @@ -252,7 +466,7 @@ public function get_class( $class_name ) { * * @return array */ - public function get_all_classes() { + public function get_all_classes(): array { return $this->classes; } @@ -260,10 +474,8 @@ public function get_all_classes() { * Get an initialized class by its full class name, including namespace. * * @param string $class_name The class name including the namespace. - * - * @return false|ModuleInterface */ - public static function get_module( $class_name ) { + public static function get_module( string $class_name ): false|ModuleInterface { return self::instance()->get_class( $class_name ); } } diff --git a/src/ModuleInterface.php b/src/ModuleInterface.php index 960ec7b..b03b7e1 100644 --- a/src/ModuleInterface.php +++ b/src/ModuleInterface.php @@ -20,22 +20,16 @@ interface ModuleInterface { * Lower number will be initialized first. * * @note This has no correlation to the `init` priority. It's just a way to allow certain classes to be initialized before others. - * - * @return int The priority of the module. */ - public function load_order(); + public function load_order(): int; /** * Checks whether the Module should run within the current context. - * - * @return bool */ - public function can_register(); + public function can_register(): bool; /** * Connects the Module with WordPress using Hooks and/or Filters. - * - * @return void */ - public function register(); + public function register(): void; } diff --git a/src/PostTypes/AbstractCorePostType.php b/src/PostTypes/AbstractCorePostType.php index 1823423..4a92884 100644 --- a/src/PostTypes/AbstractCorePostType.php +++ b/src/PostTypes/AbstractCorePostType.php @@ -22,10 +22,8 @@ abstract class AbstractCorePostType extends AbstractPostType { * Get the singular post type label. * * No-op for core post types since they are already registered by WordPress. - * - * @return string */ - public function get_singular_label() { + public function get_singular_label(): string { return ''; } @@ -33,10 +31,8 @@ public function get_singular_label() { * Get the plural post type label. * * No-op for core post types since they are already registered by WordPress. - * - * @return string */ - public function get_plural_label() { + public function get_plural_label(): string { return ''; } @@ -44,10 +40,8 @@ public function get_plural_label() { * Get the menu icon for the post type. * * No-op for core post types since they are already registered by WordPress. - * - * @return string */ - public function get_menu_icon() { + public function get_menu_icon(): string { return ''; } @@ -55,23 +49,16 @@ public function get_menu_icon() { * Checks whether the Module should run within the current context. * * True for core post types since they are already registered by WordPress. - * - * @return bool */ - public function can_register() { + public function can_register(): bool { return true; } /** * Registers a post type and associates its taxonomies. - * - * @uses $this->get_name() to get the post's type name. - * @return Bool Whether this theme has supports for this post type. */ - public function register() { + public function register(): void { $this->register_taxonomies(); $this->after_register(); - - return true; } } diff --git a/src/PostTypes/AbstractPostType.php b/src/PostTypes/AbstractPostType.php index d945df3..dfcc58a 100644 --- a/src/PostTypes/AbstractPostType.php +++ b/src/PostTypes/AbstractPostType.php @@ -47,23 +47,23 @@ abstract class AbstractPostType implements ModuleInterface { /** * Get the post type name. * - * @return string + * WordPress requires a post type key to be a non-empty lowercase string, so the contract + * states that rather than a bare string. Returning anything else is rejected by + * register_post_type() at runtime. + * + * @return lowercase-string&non-empty-string */ - abstract public function get_name(); + abstract public function get_name(): string; /** * Get the singular post type label. - * - * @return string */ - abstract public function get_singular_label(); + abstract public function get_singular_label(): string; /** * Get the plural post type label. - * - * @return string */ - abstract public function get_plural_label(); + abstract public function get_plural_label(): string; /** * Get the menu icon for the post type. @@ -71,26 +71,20 @@ abstract public function get_plural_label(); * This can be a base64 encoded SVG, a dashicons class or 'none' to leave it empty so it can be filled with CSS. * * @see https://developer.wordpress.org/resource/dashicons/ - * - * @return string */ - abstract public function get_menu_icon(); + abstract public function get_menu_icon(): string; /** * Get the menu position for the post type. - * - * @return int|null */ - public function get_menu_position() { + public function get_menu_position(): int|null { return null; } /** * Is the post type hierarchical? - * - * @return bool */ - public function is_hierarchical() { + public function is_hierarchical(): bool { return false; } @@ -99,7 +93,7 @@ public function is_hierarchical() { * * @return array */ - public function get_editor_supports() { + public function get_editor_supports(): array { $supports = [ 'title', 'editor', @@ -154,7 +148,7 @@ public function get_editor_supports() { * template_lock?: string|false, * } */ - public function get_options() { + public function get_options(): array { $options = [ 'labels' => $this->get_labels(), 'public' => true, @@ -182,7 +176,7 @@ public function get_options() { * * @return array */ - public function get_labels() { + public function get_labels(): array { $plural_label = $this->get_plural_label(); $singular_label = $this->get_singular_label(); @@ -225,25 +219,18 @@ public function get_labels() { /** * Registers a post type and associates its taxonomies. - * - * @uses $this->get_name() to get the post's type name. - * @return Bool Whether this theme has supports for this post type. */ - public function register() { + public function register(): void { $this->register_post_type(); $this->register_taxonomies(); $this->after_register(); - - return true; } /** * Registers the current post type with WordPress. - * - * @return void */ - public function register_post_type() { + public function register_post_type(): void { register_post_type( $this->get_name(), $this->get_options() @@ -252,10 +239,8 @@ public function register_post_type() { /** * Registers the taxonomies declared with the current post type. - * - * @return void */ - public function register_taxonomies() { + public function register_taxonomies(): void { $taxonomies = $this->get_supported_taxonomies(); $object_type = $this->get_name(); @@ -276,16 +261,14 @@ public function register_taxonomies() { * * @return array */ - public function get_supported_taxonomies() { + public function get_supported_taxonomies(): array { return []; } /** * Run any code after the post type has been registered. - * - * @return void */ - public function after_register() { + public function after_register(): void { // Do nothing. } } diff --git a/src/Taxonomies/AbstractTaxonomy.php b/src/Taxonomies/AbstractTaxonomy.php index c3dfdf3..ccb0ffe 100644 --- a/src/Taxonomies/AbstractTaxonomy.php +++ b/src/Taxonomies/AbstractTaxonomy.php @@ -44,40 +44,36 @@ abstract class AbstractTaxonomy implements ModuleInterface { * Used to alter the order in which classes are initialized. * * Lower number will be initialized first. - * - * @return int */ - public function load_order() { + public function load_order(): int { return 9; } /** * Get the taxonomy name. * - * @return string + * WordPress requires a taxonomy key to be a non-empty lowercase string, so the contract + * states that rather than a bare string. Returning anything else is rejected by + * register_taxonomy() at runtime. + * + * @return lowercase-string&non-empty-string */ - abstract public function get_name(); + abstract public function get_name(): string; /** * Get the singular taxonomy label. - * - * @return string */ - abstract public function get_singular_label(); + abstract public function get_singular_label(): string; /** * Get the plural taxonomy label. - * - * @return string */ - abstract public function get_plural_label(); + abstract public function get_plural_label(): string; /** * Is the taxonomy hierarchical? - * - * @return bool */ - public function is_hierarchical() { + public function is_hierarchical(): bool { return false; } @@ -85,9 +81,8 @@ public function is_hierarchical() { * Register hooks and actions. * * @uses $this->get_name() to get the taxonomy's slug. - * @return bool */ - public function register() { + public function register(): void { \register_taxonomy( $this->get_name(), $this->get_post_types(), @@ -95,8 +90,6 @@ public function register() { ); $this->after_register(); - - return true; } /** @@ -137,7 +130,7 @@ public function register() { * _builtin?: bool, * } */ - public function get_options() { + public function get_options(): array { return [ 'labels' => $this->get_labels(), 'hierarchical' => $this->is_hierarchical(), @@ -154,7 +147,7 @@ public function get_options() { * * @return array */ - public function get_labels() { + public function get_labels(): array { $plural_label = $this->get_plural_label(); $singular_label = $this->get_singular_label(); @@ -187,16 +180,14 @@ public function get_labels() { * * @return array */ - public function get_post_types() { + public function get_post_types(): array { return []; } /** * Run any code after the taxonomy has been registered. - * - * @return void */ - public function after_register() { + public function after_register(): void { // Do nothing. } } diff --git a/tests/Assets/GetAssetInfoTest.php b/tests/Assets/GetAssetInfoTest.php index d08139f..415772f 100644 --- a/tests/Assets/GetAssetInfoTest.php +++ b/tests/Assets/GetAssetInfoTest.php @@ -130,4 +130,117 @@ public function test_get_asset_info_throws_exception_when_called_without_setting slug: 'test-script' ); } + + /** + * Test get_asset_info with prefix-based slug handling (css/, js/, blocks/). + * + * @return void + */ + public function test_get_asset_info_with_prefix_based_slug() { + $asset_info = new class() { + use GetAssetInfo; + }; + + $asset_info->setup_asset_vars( + dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist', + fallback_version: '1.0.0' + ); + + // Test CSS prefix with existing fixture + $asset = $asset_info->get_asset_info( slug: 'css/test-style' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php'; + $this->assertEquals( $vars, $asset ); + + // Test JS prefix with existing fixture + $asset = $asset_info->get_asset_info( slug: 'js/test-script' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php'; + $this->assertEquals( $vars, $asset ); + + // Test blocks prefix with existing fixture + $asset = $asset_info->get_asset_info( slug: 'blocks/test-block' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/blocks/test-block.asset.php'; + $this->assertEquals( $vars, $asset ); + } + + /** + * Test get_asset_info priority order: prefix-based slugs take priority over fallback. + * + * @return void + */ + public function test_get_asset_info_priority_order_prefix_vs_fallback() { + $asset_info = new class() { + use GetAssetInfo; + }; + + $asset_info->setup_asset_vars( + dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist', + fallback_version: '1.0.0' + ); + + // Test that prefix-based slug works with existing fixtures + $asset = $asset_info->get_asset_info( slug: 'css/test-style' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php'; + $this->assertEquals( $vars, $asset ); + + // Test that fallback still works for non-prefixed slugs + $asset = $asset_info->get_asset_info( slug: 'test-script' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php'; + $this->assertEquals( $vars, $asset ); + } + + /** + * Test get_asset_info fallback behavior when direct file doesn't exist. + * + * @return void + */ + public function test_get_asset_info_fallback_when_direct_file_missing() { + $asset_info = new class() { + use GetAssetInfo; + }; + + $asset_info->setup_asset_vars( + dist_path: dirname( __DIR__, 2 ) . '/fixtures/assets/dist', + fallback_version: '1.0.0' + ); + + // Test that it falls back to JS directory first (priority order: js -> css -> blocks) + // Using existing fixture that exists in js/ directory + $asset = $asset_info->get_asset_info( slug: 'test-script' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/js/test-script.asset.php'; + $this->assertEquals( $vars, $asset ); + + // Test CSS fallback with existing fixture + $asset = $asset_info->get_asset_info( slug: 'test-style' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/css/test-style.asset.php'; + $this->assertEquals( $vars, $asset ); + + // Test blocks fallback with existing fixture + $asset = $asset_info->get_asset_info( slug: 'test-block' ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $vars = require dirname( __DIR__, 2 ) . '/fixtures/assets/dist/blocks/test-block.asset.php'; + $this->assertEquals( $vars, $asset ); + } } diff --git a/tests/Bin/GenerateClassCacheTest.php b/tests/Bin/GenerateClassCacheTest.php new file mode 100644 index 0000000..81d10db --- /dev/null +++ b/tests/Bin/GenerateClassCacheTest.php @@ -0,0 +1,232 @@ + + */ + private $temp_dirs = []; + + /** + * Remove any temporary directories created during the test. + * + * @return void + */ + protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + foreach ( $this->temp_dirs as $dir ) { + $this->remove_dir( $dir ); + } + $this->temp_dirs = []; + + parent::tearDown(); + } + + /** + * Running the command against a directory writes a readable cache of its classes. + * + * @return void + */ + public function test_generates_a_cache_for_a_directory() { + $dir = $this->example_copy( 'plugin-inc' ); + + $result = $this->run_bin( [ $dir ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertStringContainsString( 'Cached', $result['stdout'] ); + + $cache_file = $this->cache_file_path( $dir ); + $this->assertFileExists( $cache_file ); + + $cached = require $cache_file; + $this->assertContains( 'TenupFrameworkExamples\\Modules\\GreetingModule', $cached ); + $this->assertContains( 'TenupFrameworkExamples\\Support\\Formatter', $cached ); + } + + /** + * With no arguments the command prints usage to stderr and exits non-zero. + * + * @return void + */ + public function test_reports_usage_and_fails_without_arguments() { + $result = $this->run_bin( [] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( 'Usage:', $result['stderr'] ); + } + + /** + * A missing directory fails that directory (non-zero exit, error on stderr) but the command + * still processes the directories that are valid. + * + * @return void + */ + public function test_missing_directory_fails_but_valid_directories_still_cache() { + $good = $this->example_copy( 'plugin-inc' ); + $missing = sys_get_temp_dir() . '/tenup_bin_missing_' . uniqid( '', true ); + + $result = $this->run_bin( [ $missing, $good ] ); + + $this->assertSame( 1, $result['exit'] ); + $this->assertStringContainsString( $missing, $result['stderr'] ); + $this->assertStringContainsString( 'Failed to generate cache', $result['stderr'] ); + + // The valid directory was still cached despite the earlier failure. + $this->assertFileExists( $this->cache_file_path( $good ) ); + } + + /** + * Several directories can be cached in a single invocation. + * + * @return void + */ + public function test_caches_multiple_directories() { + $first = $this->example_copy( 'plugin-inc' ); + $second = $this->example_copy( 'second-inc' ); + + $result = $this->run_bin( [ $first, $second ] ); + + $this->assertSame( 0, $result['exit'], $result['stderr'] ); + $this->assertFileExists( $this->cache_file_path( $first ) ); + $this->assertFileExists( $this->cache_file_path( $second ) ); + + $cached = require $this->cache_file_path( $second ); + $this->assertContains( 'TenupFrameworkExamples\\Widgets\\Card', $cached ); + } + + /** + * Run the bin script with the given arguments, returning its stdout, stderr and exit code. + * + * @param array $args The arguments to pass after the script name. + * + * @return array{stdout: string, stderr: string, exit: int} + */ + private function run_bin( array $args ): array { + $script = dirname( __DIR__, 2 ) . '/bin/tenup-framework-generate-class-cache'; + $command = array_map( 'escapeshellarg', array_merge( [ PHP_BINARY, $script ], $args ) ); + + $descriptors = [ + 1 => [ 'pipe', 'w' ], + 2 => [ 'pipe', 'w' ], + ]; + + $process = proc_open( implode( ' ', $command ), $descriptors, $pipes ); + $this->assertIsResource( $process ); + + $stdout = (string) stream_get_contents( $pipes[1] ); + $stderr = (string) stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + $exit = proc_close( $process ); + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit' => $exit, + ]; + } + + /** + * Copy an example directory into a fresh temp directory so the command can write a cache + * into it without touching the committed examples. + * + * @param string $name The example directory name under tests/examples. + * + * @return string The path to the temp copy. + */ + private function example_copy( string $name ): string { + $source = __DIR__ . '/../examples/' . $name; + $target = sys_get_temp_dir() . '/tenup_bin_' . $name . '_' . uniqid( '', true ); + + $this->copy_dir( $source, $target ); + $this->temp_dirs[] = $target; + + return $target; + } + + /** + * The absolute path to the cache file the command writes for a directory. + * + * @param string $dir The discovery directory. + * + * @return string + */ + private function cache_file_path( string $dir ): string { + return $dir . '/' . ModuleInitialization::CACHE_DIR_NAME . '/' . ModuleInitialization::CACHE_FILENAME; + } + + /** + * Recursively copy a directory. + * + * @param string $source The source directory. + * @param string $target The target directory. + * + * @return void + */ + private function copy_dir( string $source, string $target ): void { + mkdir( $target, 0777, true ); + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $source, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $items as $item ) { + $destination = $target . '/' . $items->getSubPathname(); + if ( $item->isDir() ) { + mkdir( $destination, 0777, true ); + } else { + copy( $item->getPathname(), $destination ); + } + } + } + + /** + * Recursively remove a directory. + * + * @param string $dir The directory to remove. + * + * @return void + */ + private function remove_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $dir ); + } +} diff --git a/tests/BlockRegistrarTest.php b/tests/BlockRegistrarTest.php new file mode 100644 index 0000000..80cd0a0 --- /dev/null +++ b/tests/BlockRegistrarTest.php @@ -0,0 +1,346 @@ +assertInstanceOf( \TenupFramework\ModuleInterface::class, $block_registrar ); + $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $block_registrar ); + } + + /** + * Test that can_register returns true by default. + * + * @return void + */ + public function test_can_register_returns_true() { + $block_registrar = new TestBlockRegistrar(); + + $this->assertTrue( $block_registrar->can_register() ); + } + + /** + * Test that get_blocks_directory is abstract and must be implemented. + * + * @return void + */ + public function test_get_blocks_directory_is_abstract() { + $this->expectException( \Error::class ); + new \TenupFramework\BlockRegistrar(); + } + + /** + * Test that register method calls parent register and adds hooks. + * + * @return void + */ + public function test_register_method_adds_hooks() { + // Create a concrete test class + $block_registrar = new TestBlockRegistrar(); + + // This should not throw an exception + $block_registrar->register(); + $this->assertTrue( true ); // If we get here, register() worked + } + + /** + * Test that get_blocks_directory returns an array. + * + * @return void + */ + public function test_get_blocks_directory_returns_array() { + $block_registrar = new TestBlockRegistrar(); + $directories = $block_registrar->get_blocks_directory(); + + $this->assertIsArray( $directories ); + $this->assertCount( 1, $directories ); + $this->assertEquals( '/test/blocks/', $directories[0] ); + } + + /** + * Test that multiple directories can be returned. + * + * @return void + */ + public function test_multiple_directories_support() { + $block_registrar = new TestMultiDirectoryBlockRegistrar(); + $directories = $block_registrar->get_blocks_directory(); + + $this->assertIsArray( $directories ); + $this->assertCount( 3, $directories ); + $this->assertEquals( '/test/blocks/', $directories[0] ); + $this->assertEquals( '/test/custom-blocks/', $directories[1] ); + $this->assertEquals( '/test/vendor-blocks/', $directories[2] ); + } + + /** + * Test that empty directory array is handled correctly. + * + * @return void + */ + public function test_empty_directory_array_support() { + $block_registrar = new TestEmptyDirectoryBlockRegistrar(); + $directories = $block_registrar->get_blocks_directory(); + + $this->assertIsArray( $directories ); + $this->assertEmpty( $directories ); + } + + /** + * Test register_blocks with non-existent directories. + * + * @return void + */ + public function test_register_blocks_with_non_existent_directories() { + $block_registrar = new TestBlockRegistrar(); + + // This test verifies the method exists and can be called + // In a real WordPress environment, it would handle non-existent directories gracefully + $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) ); + } + + /** + * Test register_blocks with empty directory array. + * + * @return void + */ + public function test_register_blocks_with_empty_directory_array() { + $block_registrar = new TestEmptyDirectoryBlockRegistrar(); + + // This test verifies the method exists and can be called + // In a real WordPress environment, it would handle empty directories gracefully + $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) ); + } + + /** + * Test get_block_options without markup.php file. + * + * @return void + */ + public function test_get_block_options_without_markup() { + $block_registrar = new TestBlockRegistrar(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass( $block_registrar ); + $method = $reflection->getMethod( 'get_block_options' ); + $method->setAccessible( true ); + + $options = $method->invoke( $block_registrar, '/test/block-without-markup/' ); + + $this->assertIsArray( $options ); + $this->assertEmpty( $options ); + } + + /** + * Test get_block_options with markup.php file. + * + * @return void + */ + public function test_get_block_options_with_markup() { + $block_registrar = new TestBlockRegistrar(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass( $block_registrar ); + $method = $reflection->getMethod( 'get_block_options' ); + $method->setAccessible( true ); + + // Mock file_exists to return true for markup.php + $original_file_exists = 'file_exists'; + if ( function_exists( 'file_exists' ) ) { + // In a real test environment, you'd mock this properly + // For now, we'll test the structure + $options = $method->invoke( $block_registrar, '/test/block-with-markup/' ); + + // The method should return an array (empty if file doesn't exist) + $this->assertIsArray( $options ); + } + } + + /** + * Test register_allowed_block_types method. + * + * @return void + */ + public function test_register_allowed_block_types() { + $block_registrar = new TestBlockRegistrar(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass( $block_registrar ); + $method = $reflection->getMethod( 'register_allowed_block_types' ); + $method->setAccessible( true ); + + $block_names = [ 'test/block1', 'test/block2' ]; + + // This should not throw an exception + $method->invoke( $block_registrar, $block_names ); + $this->assertTrue( true ); // If we get here, no exception was thrown + } + + /** + * Test WordPress hook registration. + * + * @return void + */ + public function test_wordpress_hook_registration() { + $block_registrar = new TestBlockRegistrar(); + + // This should not throw an exception when registering hooks + $block_registrar->register(); + $this->assertTrue( true ); // If we get here, register() worked without throwing + } + + /** + * Test that multiple BlockRegistrar instances don't conflict. + * + * @return void + */ + public function test_multiple_instances_no_conflict() { + // Create two different instances + $theme_blocks = new TestBlockRegistrar(); + $plugin_blocks = new TestMultiDirectoryBlockRegistrar(); + + // Test that both instances can be created without conflicts + $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $theme_blocks ); + $this->assertInstanceOf( \TenupFramework\BlockRegistrar::class, $plugin_blocks ); + $this->assertNotSame( $theme_blocks, $plugin_blocks ); + } + + /** + * Test static block name tracking. + * + * @return void + */ + public function test_static_block_name_tracking() { + // Use reflection to access static properties + $reflection = new \ReflectionClass( \TenupFramework\BlockRegistrar::class ); + + // Test that static properties exist + $this->assertTrue( $reflection->hasProperty( 'registered_block_names' ) ); + $this->assertTrue( $reflection->hasProperty( 'filter_registered' ) ); + $this->assertTrue( $reflection->hasProperty( 'block_sources' ) ); + + // Test that the static filter method exists + $this->assertTrue( $reflection->hasMethod( 'filter_allowed_block_types' ) ); + } + + /** + * Test block conflict detection methods. + * + * @return void + */ + public function test_block_conflict_detection() { + // Test conflict detection methods exist + $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'has_block_conflict' ) ); + $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'get_block_source' ) ); + $this->assertTrue( method_exists( \TenupFramework\BlockRegistrar::class, 'get_all_block_sources' ) ); + + // Test initial state + $this->assertFalse( \TenupFramework\BlockRegistrar::has_block_conflict( 'test/block' ) ); + $this->assertNull( \TenupFramework\BlockRegistrar::get_block_source( 'test/block' ) ); + $this->assertIsArray( \TenupFramework\BlockRegistrar::get_all_block_sources() ); + } + + /** + * Test block source tracking. + * + * @return void + */ + public function test_block_source_tracking() { + // Manually add a block source for testing + \TenupFramework\BlockRegistrar::$block_sources['test/block'] = 'TestClass'; + + // Test conflict detection + $this->assertTrue( \TenupFramework\BlockRegistrar::has_block_conflict( 'test/block' ) ); + $this->assertEquals( 'TestClass', \TenupFramework\BlockRegistrar::get_block_source( 'test/block' ) ); + + // Test getting all sources + $sources = \TenupFramework\BlockRegistrar::get_all_block_sources(); + $this->assertArrayHasKey( 'test/block', $sources ); + $this->assertEquals( 'TestClass', $sources['test/block'] ); + + // Clean up + unset( \TenupFramework\BlockRegistrar::$block_sources['test/block'] ); + } + + /** + * Test path validation edge cases. + * + * @return void + */ + public function test_path_validation_edge_cases() { + $block_registrar = new TestBlockRegistrar(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass( $block_registrar ); + $method = $reflection->getMethod( 'validate_directory_path' ); + $method->setAccessible( true ); + + // Test invalid paths + $this->assertFalse( $method->invoke( $block_registrar, '' ) ); + $this->assertFalse( $method->invoke( $block_registrar, '../malicious' ) ); + $this->assertFalse( $method->invoke( $block_registrar, './relative' ) ); + $this->assertFalse( $method->invoke( $block_registrar, str_repeat( 'a', 1001 ) ) ); + + // Test valid paths + $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '/valid/path' ) ); + $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '/valid/path/' ) ); + $this->assertEquals( '/valid/path/', $method->invoke( $block_registrar, '\\valid\\path' ) ); + } + + /** + * Test block.json validation edge cases. + * + * @return void + */ + public function test_block_json_validation_edge_cases() { + $block_registrar = new TestBlockRegistrar(); + + // Use reflection to access protected method + $reflection = new \ReflectionClass( $block_registrar ); + $method = $reflection->getMethod( 'validate_block_json' ); + $method->setAccessible( true ); + + // Test invalid file paths + $this->assertFalse( $method->invoke( $block_registrar, '/non/existent/file.json' ) ); + + // Test invalid JSON (this would require creating actual files in tests) + // For now, just test that the method exists and handles errors + $this->assertTrue( method_exists( $block_registrar, 'validate_block_json' ) ); + } + + /** + * Test WordPress availability check. + * + * @return void + */ + public function test_wordpress_availability_check() { + $block_registrar = new TestBlockRegistrar(); + + // Test that the method exists and can be called + // In a real WordPress environment, it would check for function availability + $this->assertTrue( method_exists( $block_registrar, 'register_blocks' ) ); + } +} diff --git a/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php new file mode 100644 index 0000000..23fd831 --- /dev/null +++ b/tests/Cache/ReadOnlyFileDiscoverCacheDriverTest.php @@ -0,0 +1,118 @@ +dir = sys_get_temp_dir() . '/tenup_ro_driver_' . uniqid( '', true ); + mkdir( $this->dir ); + + return $this->dir; + } + + /** + * Remove the temporary directory after a test. + * + * @return void + */ + protected function tearDown(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( '' !== $this->dir && is_dir( $this->dir ) ) { + $files = glob( $this->dir . '/*' ); + if ( false !== $files ) { + array_map( 'unlink', $files ); + } + rmdir( $this->dir ); + } + $this->dir = ''; + + parent::tearDown(); + } + + /** + * The constructor must not create the cache directory: the runtime never writes. + * + * @return void + */ + public function test_constructor_does_not_create_directory() { + $missing = sys_get_temp_dir() . '/tenup_ro_missing_' . uniqid( '', true ); + + new ReadOnlyFileDiscoverCacheDriver( $missing, false, 'cache.php' ); + + $this->assertDirectoryDoesNotExist( $missing ); + } + + /** + * put() is a no-op: nothing is written to disk. + * + * @return void + */ + public function test_put_writes_nothing() { + $dir = $this->make_dir(); + $driver = new ReadOnlyFileDiscoverCacheDriver( $dir, false, 'cache.php' ); + + $driver->put( 'id', [ 'Foo\\Bar' ] ); + + $this->assertFalse( $driver->has( 'id' ) ); + $this->assertFileDoesNotExist( $dir . '/cache.php' ); + } + + /** + * forget() is a no-op: an existing cache file is left untouched. + * + * @return void + */ + public function test_forget_deletes_nothing() { + $dir = $this->make_dir(); + file_put_contents( $dir . '/cache.php', 'forget( 'id' ); + + $this->assertFileExists( $dir . '/cache.php' ); + } + + /** + * has()/get() read an existing cache file produced with the same settings the + * build-time generator uses (serialize = false, an explicit filename). + * + * @return void + */ + public function test_has_and_get_read_an_existing_file() { + $dir = $this->make_dir(); + file_put_contents( $dir . '/cache.php', "assertTrue( $driver->has( 'id' ) ); + $this->assertSame( [ 'Foo\\Bar' ], $driver->get( 'id' ) ); + } +} diff --git a/tests/Core/EmojiTest.php b/tests/Core/EmojiTest.php new file mode 100644 index 0000000..f9250f1 --- /dev/null +++ b/tests/Core/EmojiTest.php @@ -0,0 +1,226 @@ +assertInstanceOf( \TenupFramework\ModuleInterface::class, $emoji ); + } + + /** + * Test that Emoji can be registered. + * + * @return void + */ + public function test_can_register() { + $emoji = new Emoji(); + + $this->assertTrue( $emoji->can_register() ); + } + + /** + * Test that Emoji has correct load order. + * + * @return void + */ + public function test_load_order() { + $emoji = new Emoji(); + + $this->assertEquals( 5, $emoji->load_order() ); + } + + /** + * Test that register method can be called without errors. + * + * @return void + */ + public function test_register_can_be_called() { + $emoji = new Emoji(); + + // Mock WordPress functions to prevent actual function calls + \Brain\Monkey\Functions\when( 'remove_action' )->justReturn( true ); + \Brain\Monkey\Functions\when( 'remove_filter' )->justReturn( true ); + \Brain\Monkey\Functions\when( 'add_filter' )->justReturn( true ); + + // This should not throw any exceptions + $emoji->register(); + + // If we get here, the method executed successfully + $this->assertTrue( true ); + } + + /** + * Test that register method exists and is callable. + * + * @return void + */ + public function test_register_method_exists() { + $emoji = new Emoji(); + + $this->assertTrue( method_exists( $emoji, 'register' ) ); + $this->assertTrue( is_callable( [ $emoji, 'register' ] ) ); + } + + /** + * Test that Emoji has the expected WordPress function calls in register method. + * + * @return void + */ + public function test_register_method_contains_expected_calls() { + $reflection = new \ReflectionClass( Emoji::class ); + $method = $reflection->getMethod( 'register' ); + $filename = $method->getFileName(); + $start_line = $method->getStartLine(); + $end_line = $method->getEndLine(); + + // Read the method source code + $lines = file( $filename ); + $method_source = implode( '', array_slice( $lines, $start_line - 1, $end_line - $start_line + 1 ) ); + + // Verify the method contains the expected remove_action calls + $this->assertStringContainsString( "remove_action( 'wp_head', 'print_emoji_detection_script', 7 )", $method_source ); + $this->assertStringContainsString( "remove_action( 'admin_print_scripts', 'print_emoji_detection_script' )", $method_source ); + $this->assertStringContainsString( "remove_action( 'wp_print_styles', 'print_emoji_styles' )", $method_source ); + $this->assertStringContainsString( "remove_action( 'admin_print_styles', 'print_emoji_styles' )", $method_source ); + + // Verify the method contains the expected remove_filter calls + $this->assertStringContainsString( "remove_filter( 'the_content_feed', 'wp_staticize_emoji' )", $method_source ); + $this->assertStringContainsString( "remove_filter( 'comment_text_rss', 'wp_staticize_emoji' )", $method_source ); + $this->assertStringContainsString( "remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' )", $method_source ); + + // Verify the method contains the expected add_filter calls + $this->assertStringContainsString( "add_filter( 'tiny_mce_plugins', [ \$this, 'disable_emojis_tinymce' ] )", $method_source ); + $this->assertStringContainsString( "add_filter( 'wp_resource_hints', [ \$this, 'disable_emoji_dns_prefetch' ], 10, 2 )", $method_source ); + } + + /** + * Test that disable_emojis_tinymce method exists and is callable. + * + * @return void + */ + public function test_disable_emojis_tinymce_method_exists() { + $emoji = new Emoji(); + + $this->assertTrue( method_exists( $emoji, 'disable_emojis_tinymce' ) ); + $this->assertTrue( is_callable( [ $emoji, 'disable_emojis_tinymce' ] ) ); + } + + /** + * Test that disable_emoji_dns_prefetch method exists and is callable. + * + * @return void + */ + public function test_disable_emoji_dns_prefetch_method_exists() { + $emoji = new Emoji(); + + $this->assertTrue( method_exists( $emoji, 'disable_emoji_dns_prefetch' ) ); + $this->assertTrue( is_callable( [ $emoji, 'disable_emoji_dns_prefetch' ] ) ); + } + + /** + * Test disable_emojis_tinymce method functionality. + * + * @return void + */ + public function test_disable_emojis_tinymce_functionality() { + $emoji = new Emoji(); + + // Test with wpemoji plugin present + $plugins_with_emoji = [ 'wordpress', 'wpemoji', 'media' ]; + $result = $emoji->disable_emojis_tinymce( $plugins_with_emoji ); + + $this->assertNotContains( 'wpemoji', $result ); + $this->assertContains( 'wordpress', $result ); + $this->assertContains( 'media', $result ); + + // Test with wpemoji plugin not present + $plugins_without_emoji = [ 'wordpress', 'media' ]; + $result = $emoji->disable_emojis_tinymce( $plugins_without_emoji ); + + $this->assertEquals( $plugins_without_emoji, $result ); + } + + /** + * Test disable_emoji_dns_prefetch method functionality. + * + * @return void + */ + public function test_disable_emoji_dns_prefetch_functionality() { + $emoji = new Emoji(); + + // Mock apply_filters for emoji_svg_url + \Brain\Monkey\Filters\expectApplied( 'emoji_svg_url' ) + ->once() + ->andReturn( 'https://s.w.org/images/core/emoji/2/svg/' ); + + $urls = [ + 'https://fonts.googleapis.com', + 'https://s.w.org/images/core/emoji/2/svg/', + 'https://example.com', + ]; + + $result = $emoji->disable_emoji_dns_prefetch( $urls, 'dns-prefetch' ); + + $this->assertNotContains( 'https://s.w.org/images/core/emoji/2/svg/', $result ); + $this->assertContains( 'https://fonts.googleapis.com', $result ); + $this->assertContains( 'https://example.com', $result ); + + // Test with different relation type + $result = $emoji->disable_emoji_dns_prefetch( $urls, 'preconnect' ); + $this->assertEquals( $urls, $result ); + } + + /** + * Test that Emoji can be instantiated multiple times. + * + * @return void + */ + public function test_multiple_instances() { + $emoji_1 = new Emoji(); + $emoji_2 = new Emoji(); + + $this->assertInstanceOf( Emoji::class, $emoji_1 ); + $this->assertInstanceOf( Emoji::class, $emoji_2 ); + $this->assertNotSame( $emoji_1, $emoji_2 ); + } + + /** + * Test that Emoji uses the Module trait. + * + * @return void + */ + public function test_uses_module_trait() { + $emoji = new Emoji(); + + // Check that the class has the methods from the Module trait + $this->assertTrue( method_exists( $emoji, 'load_order' ) ); + $this->assertTrue( method_exists( $emoji, 'can_register' ) ); + $this->assertTrue( method_exists( $emoji, 'register' ) ); + } +} diff --git a/tests/Core/HeadOverridesTest.php b/tests/Core/HeadOverridesTest.php new file mode 100644 index 0000000..b15d940 --- /dev/null +++ b/tests/Core/HeadOverridesTest.php @@ -0,0 +1,137 @@ +assertInstanceOf( \TenupFramework\ModuleInterface::class, $head_overrides ); + } + + /** + * Test that HeadOverrides can be registered. + * + * @return void + */ + public function test_can_register() { + $head_overrides = new HeadOverrides(); + + $this->assertTrue( $head_overrides->can_register() ); + } + + /** + * Test that HeadOverrides has correct load order. + * + * @return void + */ + public function test_load_order() { + $head_overrides = new HeadOverrides(); + + $this->assertEquals( 5, $head_overrides->load_order() ); + } + + /** + * Test that register method can be called without errors. + * + * @return void + */ + public function test_register_can_be_called() { + $head_overrides = new HeadOverrides(); + + // Mock remove_action to prevent actual WordPress function calls + \Brain\Monkey\Functions\when( 'remove_action' )->justReturn( true ); + + // This should not throw any exceptions + $head_overrides->register(); + + // If we get here, the method executed successfully + $this->assertTrue( true ); + } + + /** + * Test that register method exists and is callable. + * + * @return void + */ + public function test_register_method_exists() { + $head_overrides = new HeadOverrides(); + + $this->assertTrue( method_exists( $head_overrides, 'register' ) ); + $this->assertTrue( is_callable( [ $head_overrides, 'register' ] ) ); + } + + /** + * Test that HeadOverrides has the expected WordPress function calls in register method. + * + * @return void + */ + public function test_register_method_contains_expected_calls() { + $reflection = new \ReflectionClass( HeadOverrides::class ); + $method = $reflection->getMethod( 'register' ); + $filename = $method->getFileName(); + $start_line = $method->getStartLine(); + $end_line = $method->getEndLine(); + + // Read the method source code + $lines = file( $filename ); + $method_source = implode( '', array_slice( $lines, $start_line - 1, $end_line - $start_line + 1 ) ); + + // Verify the method contains the expected remove_action calls + $this->assertStringContainsString( "remove_action( 'wp_head', 'wp_generator' )", $method_source ); + $this->assertStringContainsString( "remove_action( 'wp_head', 'wlwmanifest_link' )", $method_source ); + $this->assertStringContainsString( "remove_action( 'wp_head', 'rsd_link' )", $method_source ); + } + + /** + * Test that HeadOverrides can be instantiated multiple times. + * + * @return void + */ + public function test_multiple_instances() { + $head_overrides_1 = new HeadOverrides(); + $head_overrides_2 = new HeadOverrides(); + + $this->assertInstanceOf( HeadOverrides::class, $head_overrides_1 ); + $this->assertInstanceOf( HeadOverrides::class, $head_overrides_2 ); + $this->assertNotSame( $head_overrides_1, $head_overrides_2 ); + } + + /** + * Test that HeadOverrides uses the Module trait. + * + * @return void + */ + public function test_uses_module_trait() { + $head_overrides = new HeadOverrides(); + + // Check that the class has the methods from the Module trait + $this->assertTrue( method_exists( $head_overrides, 'load_order' ) ); + $this->assertTrue( method_exists( $head_overrides, 'can_register' ) ); + $this->assertTrue( method_exists( $head_overrides, 'register' ) ); + } +} diff --git a/tests/Debug/LoaderDebugTest.php b/tests/Debug/LoaderDebugTest.php new file mode 100644 index 0000000..6eab4fd --- /dev/null +++ b/tests/Debug/LoaderDebugTest.php @@ -0,0 +1,522 @@ + + */ + private function sample_record( string $directory = '/srv/site/wp-content/plugins/demo/inc' ): array { + return [ + 'directory' => $directory, + 'cache_file' => $directory . '/class-loader-cache/class-loader-cache-v2.php', + 'cache_exists' => false, + 'cache_used' => false, + 'cache_disabled' => false, + 'classes' => [ 'TenupTmp\\Widget' ], + 'version' => '1.3.0', + 'reference' => 'abcdef1234567890', + 'discovery_seconds' => 0.0123, + 'lookup_seconds' => 0.0456, + ]; + } + + /** + * Stub the functions record() needs, with the tooling enabled. + * + * @return void + */ + private function stub_enabled() { + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + } + + /** + * is_enabled() is false when the enable filter returns false. + * + * @return void + */ + public function test_is_enabled_false_when_filter_disables() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->justReturn( false ); + + $this->assertFalse( LoaderDebug::is_enabled() ); + } + + /** + * is_enabled() is false when the disable constant is set. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_is_enabled_false_when_constant_defined() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + define( 'TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG', true ); + + $this->assertFalse( LoaderDebug::is_enabled() ); + } + + /** + * record() stores the record when enabled. + * + * @return void + */ + public function test_record_stores_when_enabled() { + $this->stub_enabled(); + + LoaderDebug::record( $this->sample_record() ); + + $this->assertCount( 1, LoaderDebug::get_loaders() ); + } + + /** + * record() accumulates multiple records. + * + * @return void + */ + public function test_records_accumulate() { + $this->stub_enabled(); + + LoaderDebug::record( $this->sample_record( '/a/inc' ) ); + LoaderDebug::record( $this->sample_record( '/b/inc' ) ); + + $this->assertCount( 2, LoaderDebug::get_loaders() ); + } + + /** + * record() stores nothing when disabled. + * + * @return void + */ + public function test_record_skips_when_disabled() { + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->justReturn( false ); + + LoaderDebug::record( $this->sample_record() ); + + $this->assertSame( [], LoaderDebug::get_loaders() ); + } + + /** + * The callback registered on the aggregation filter merges this copy's records into + * whatever other copies have already contributed. + * + * @return void + */ + public function test_aggregation_filter_merges_records() { + $captured = null; + + when( 'add_action' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + when( 'add_filter' )->alias( + static function ( $hook, $callback ) use ( &$captured ) { + if ( LoaderDebug::FILTER === $hook ) { + $captured = $callback; + } + return true; + } + ); + + LoaderDebug::record( $this->sample_record( '/b/inc' ) ); + + $this->assertIsCallable( $captured ); + + // A record contributed by another copy should be preserved alongside ours. + $existing = [ [ 'directory' => '/a/inc' ] ]; + $merged = $captured( $existing ); + + $this->assertCount( 2, $merged ); + $this->assertSame( '/a/inc', $merged[0]['directory'] ); + $this->assertSame( '/b/inc', $merged[1]['directory'] ); + } + + /** + * render_page() lists each loader and the classes it loaded. + * + * @return void + */ + public function test_render_page_lists_loaders_and_classes() { + $this->stub_render_environment(); + + LoaderDebug::record( $this->sample_record() ); + + $output = $this->capture_render(); + + $this->assertStringContainsString( 'WP Framework Loaders', $output ); + $this->assertStringContainsString( '/srv/site/wp-content/plugins/demo/inc', $output ); + $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); + $this->assertStringContainsString( 'Check this cache for staleness', $output ); + + // The recorded discovery/lookup timings are surfaced on the page. + $this->assertStringContainsString( 'Discovery time', $output ); + $this->assertStringContainsString( 'Class lookup time', $output ); + $this->assertStringContainsString( '12.30 ms', $output ); // 0.0123s discovery. + $this->assertStringContainsString( '45.60 ms', $output ); // 0.0456s lookup. + } + + /** + * owner_label() derives a plugin name when the directory sits under the plugins root. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_owner_label_derives_plugin_name() { + define( 'WP_PLUGIN_DIR', '/srv/site/wp-content/plugins' ); + + $method = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( 'owner_label' ); + $method->setAccessible( true ); + + $this->assertSame( + 'Plugin: demo', + $method->invoke( null, '/srv/site/wp-content/plugins/demo/inc' ) + ); + } + + /** + * render_page() reports drift when a staleness check is requested with a valid nonce. + * + * @return void + */ + public function test_render_page_reports_staleness_drift() { + $this->stub_render_environment(); + when( 'wp_verify_nonce' )->justReturn( true ); + + $dir = $this->make_temp_class_dir(); + + // The loaded list (in the record) is deliberately out of date versus what is on disk. + $record = $this->sample_record( $dir ); + $record['classes'] = [ 'TenupTmp\\Old' ]; + LoaderDebug::record( $record ); + + $_GET['check'] = md5( $dir ); + $_GET['_wpnonce'] = 'test'; + + $output = $this->capture_render(); + + unset( $_GET['check'], $_GET['_wpnonce'] ); + $this->remove_temp_dir( $dir ); + + $this->assertStringContainsString( 'Stale', $output ); + $this->assertStringContainsString( 'TenupTmp\\Widget', $output ); // On disk, missing from cache. + $this->assertStringContainsString( 'TenupTmp\\Old', $output ); // In cache, gone from disk. + // The drift notice also reports a real, positive live-discovery duration. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); + } + + /** + * render_page() confirms an up-to-date cache and reports how long the live scan took when a + * staleness check is requested and the loaded list matches disk. + * + * @return void + */ + public function test_render_page_reports_up_to_date_and_timing() { + $this->stub_render_environment(); + when( 'wp_verify_nonce' )->justReturn( true ); + + $dir = $this->make_temp_class_dir(); + + // The loaded list matches what is actually on disk (the single Widget class). + $record = $this->sample_record( $dir ); + $record['classes'] = [ 'TenupTmp\\Widget' ]; + LoaderDebug::record( $record ); + + $_GET['check'] = md5( $dir ); + $_GET['_wpnonce'] = 'test'; + + $output = $this->capture_render(); + + unset( $_GET['check'], $_GET['_wpnonce'] ); + $this->remove_temp_dir( $dir ); + + $this->assertStringContainsString( 'Up to date', $output ); + // Require a real, positive duration — this must NOT match the "Live discovery took —." + // placeholder that format_duration() emits for a non-positive/absent value. + $this->assertMatchesRegularExpression( '/Live discovery took \d[\d.,]* (ms|s)\./', $output ); + } + + /** + * cache_state() maps each combination of the record flags to the expected severity and badge. + * + * @dataProvider cache_state_provider + * + * @param array $flags The cache_* flags to set on the record. + * @param string $expected_sev The expected severity. + * @param string $expected_snippet A substring expected in the badge. + * + * @return void + */ + public function test_cache_state_resolves_expected_states( array $flags, string $expected_sev, string $expected_snippet ) { + $state = $this->invoke_protected( 'cache_state', [ array_merge( $this->sample_record(), $flags ) ] ); + + $this->assertSame( $expected_sev, $state['severity'] ); + $this->assertStringContainsString( $expected_snippet, $state['badge'] ); + } + + /** + * Data for test_cache_state_resolves_expected_states. + * + * @return array, 1: string, 2: string}> + */ + public function cache_state_provider(): array { + return [ + 'disabled' => [ [ 'cache_disabled' => true ], 'warn', 'disabled' ], + 'uncached' => [ + [ + 'cache_exists' => false, + 'cache_used' => false, + ], + 'warn', + 'Uncached', + ], + 'present but unused' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + ], + 'error', + 'not used', + ], + 'failed to load' => [ + [ + 'cache_exists' => true, + 'cache_used' => false, + 'cache_failed' => true, + ], + 'error', + 'failed to load', + ], + 'in use' => [ + [ + 'cache_exists' => true, + 'cache_used' => true, + ], + 'ok', + 'in use', + ], + ]; + } + + /** + * legacy_files() reports files in the cache directory that are not the current cache file, + * and nothing when the directory is clean or absent. + * + * @return void + */ + public function test_legacy_files_detects_unexpected_files() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + + $current = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $current, 'assertSame( [], $this->invoke_protected( 'legacy_files', [ $current ] ) ); + + // A leftover file from an older version is reported. + file_put_contents( $cache_dir . '/discoverer-cache-TenupFramework', 'x' ); + $found = $this->invoke_protected( 'legacy_files', [ $current ] ); + $this->assertContains( 'discoverer-cache-TenupFramework', $found ); + $this->assertNotContains( 'class-loader-cache-v2.php', $found ); + + $this->remove_temp_dir( $dir ); + } + + /** + * legacy_files() is empty when the cache directory does not exist. + * + * @return void + */ + public function test_legacy_files_empty_when_directory_absent() { + $missing = sys_get_temp_dir() . '/tenup_missing_' . uniqid( '', true ) . '/class-loader-cache-v2.php'; + + $this->assertSame( [], $this->invoke_protected( 'legacy_files', [ $missing ] ) ); + } + + /** + * format_duration() picks a sensible unit and renders a placeholder for non-positive input. + * + * @dataProvider duration_provider + * + * @param mixed $seconds The duration in seconds. + * @param string $expected The expected rendered string. + * + * @return void + */ + public function test_format_duration( $seconds, string $expected ) { + $this->assertSame( $expected, $this->invoke_protected( 'format_duration', [ $seconds ] ) ); + } + + /** + * Data for test_format_duration. + * + * @return array + */ + public function duration_provider(): array { + return [ + 'zero' => [ 0.0, '—' ], + 'negative' => [ -0.005, '—' ], + 'non-numeric' => [ 'nope', '—' ], + 'not-a-number' => [ NAN, '—' ], + 'infinite' => [ INF, '—' ], + 'sub-milli' => [ 0.0004, '0.400 ms' ], + 'milliseconds' => [ 0.0123, '12.30 ms' ], + 'seconds' => [ 1.5, '1.50 s' ], + ]; + } + + /** + * cache_detail() renders "Built ago · · " with the build time in UTC. + * + * @return void + */ + public function test_cache_detail_shows_size_and_utc_build_time() { + when( 'human_time_diff' )->justReturn( '5 minutes' ); + when( 'size_format' )->alias( static fn( $bytes ) => $bytes . ' B' ); + + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/class-loader-cache'; + mkdir( $cache_dir ); + $cache_file = $cache_dir . '/class-loader-cache-v2.php'; + file_put_contents( $cache_file, 'invoke_protected( 'cache_detail', [ [ 'cache_file' => $cache_file ] ] ); + + $this->assertStringContainsString( 'Built 5 minutes ago', $detail ); + // The absolute build time is the file mtime rendered in UTC as the trailing segment. + $expected_utc = gmdate( 'Y-m-d H:i:s', (int) filemtime( $cache_file ) ) . ' UTC'; + $this->assertStringContainsString( '· ' . $expected_utc, $detail ); + $this->assertMatchesRegularExpression( '/·\s*\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/', $detail ); + + $this->remove_temp_dir( $dir ); + } + + /** + * Invoke a protected static method on LoaderDebug via reflection. + * + * @param string $method The method name. + * @param array $args The arguments. + * + * @return mixed + */ + private function invoke_protected( string $method, array $args ) { + $reflection = ( new \ReflectionClass( LoaderDebug::class ) )->getMethod( $method ); + $reflection->setAccessible( true ); + + return $reflection->invokeArgs( null, $args ); + } + + /** + * Stub everything render_page() touches, with the tooling enabled and the current user + * capable. apply_filters returns this copy's records for the aggregation filter. + * + * @return void + */ + private function stub_render_environment() { + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'current_user_can' )->justReturn( true ); + when( 'sanitize_text_field' )->returnArg( 1 ); + when( 'wp_unslash' )->returnArg( 1 ); + when( 'admin_url' )->alias( + static function ( $path = '' ) { + return 'http://example.test/wp-admin/' . $path; + } + ); + when( 'add_query_arg' )->alias( + static function ( $args, $url ) { + return $url . '?' . http_build_query( (array) $args ); + } + ); + when( 'wp_nonce_url' )->returnArg( 1 ); + when( 'apply_filters' )->alias( + static function ( $hook, $value = null ) { + if ( LoaderDebug::FILTER === $hook ) { + return LoaderDebug::get_loaders(); + } + return $value; + } + ); + } + + /** + * Capture the output of render_page(). + * + * @return string + */ + private function capture_render(): string { + ob_start(); + LoaderDebug::render_page(); + return (string) ob_get_clean(); + } + + /** + * Create a temporary directory containing a single discoverable class. + * + * @return string The created directory path. + */ + private function make_temp_class_dir(): string { + $dir = sys_get_temp_dir() . '/tenup_loader_debug_' . uniqid( '', true ); + mkdir( $dir ); + file_put_contents( $dir . '/Widget.php', "isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $dir ); + } +} diff --git a/tests/FrameworkTestSetup.php b/tests/FrameworkTestSetup.php index 587344b..d879f85 100644 --- a/tests/FrameworkTestSetup.php +++ b/tests/FrameworkTestSetup.php @@ -53,6 +53,9 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubs( [ 'wp_get_environment_type' => 'local', + // Default to the front end so existing tests don't trigger admin-only debug + // recording; admin tests override this with their own stub. + 'is_admin' => false, 'sanitize_title' => function ( $title ) { return str_replace( ' ', '-', strtolower( $title ) ); }, @@ -68,6 +71,51 @@ protected function setUp(): void { // phpcs:ignore WordPress.NamingConventions.V stubEscapeFunctions(); stubTranslationFunctions(); + + $this->reset_module_initialization(); + $this->reset_loader_debug(); + } + + /** + * Reset the ModuleInitialization singleton so its accumulated `$classes` do not leak between + * tests. The suite is not process-isolated (the trait-level annotation does not take effect), + * so without this a class registered in one test would be seen as "already initialized" in a + * later one. + * + * @return void + */ + protected function reset_module_initialization(): void { + if ( ! class_exists( \TenupFramework\ModuleInitialization::class ) ) { + return; + } + + $instance = ( new \ReflectionClass( \TenupFramework\ModuleInitialization::class ) )->getProperty( 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + } + + /** + * Reset the static state of the LoaderDebug registry so each test starts clean, + * independent of test execution order or process isolation. + * + * @return void + */ + protected function reset_loader_debug(): void { + if ( ! class_exists( \TenupFramework\Debug\LoaderDebug::class ) ) { + return; + } + + $reflection = new \ReflectionClass( \TenupFramework\Debug\LoaderDebug::class ); + + $loaders = $reflection->getProperty( 'loaders' ); + $loaders->setAccessible( true ); + $loaders->setValue( null, [] ); + + $booted = $reflection->getProperty( 'booted' ); + $booted->setAccessible( true ); + $booted->setValue( null, false ); + + unset( $GLOBALS['tenup_framework_debug_page_registered'] ); } /** diff --git a/tests/ModuleInitializationTest.php b/tests/ModuleInitializationTest.php index 2984b67..5f054ff 100644 --- a/tests/ModuleInitializationTest.php +++ b/tests/ModuleInitializationTest.php @@ -10,7 +10,7 @@ namespace TenupFrameworkTests; use PHPUnit\Framework\TestCase; -use function Brain\Monkey\Functions\stubs; +use function Brain\Monkey\Functions\when; /** * Test Class @@ -42,11 +42,14 @@ public function test_it_can_find_classes() { */ public function test_it_can_find_classes_to_register() { $class = \TenupFramework\ModuleInitialization::instance(); - $class->init_classes( dirname( __DIR__, 1 ) . '/src/' ); + $class->init_classes( dirname( __DIR__, 1 ) . '/fixtures/classes' ); $classes = $class->get_all_classes(); - // Check that we have only classes that extend Module and more than 0. - $this->assertGreaterThanOrEqual( 0, count( $classes ) ); + // The registered set is non-empty and contains only ModuleInterface implementations. + $this->assertNotEmpty( $classes ); + foreach ( $classes as $registered ) { + $this->assertInstanceOf( \TenupFramework\ModuleInterface::class, $registered ); + } } /** @@ -151,72 +154,361 @@ public function testIsClassFullyLoadable() { /** - * Ensure it returns false if VIP_GO_APP_ENVIRONMENT is defined. + * generate_cache() writes a readable cache file and returns the discovered classes. + * + * @return void + */ + public function test_generate_cache_writes_a_readable_cache_file() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $cached = $module_init->generate_cache( $dir ); + + $this->assertFileExists( $this->cache_file_path( $dir ) ); + $this->assertContains( 'TenupTmp\\Widget', $cached ); + + $this->remove_temp_dir( $dir ); + } + + /** + * The runtime read path uses the cache file when one is present. * * @return void */ - public function test_should_use_cache_returns_false_when_vip_env_is_defined() { - define( 'VIP_GO_APP_ENVIRONMENT', true ); + public function test_get_classes_reads_the_cache_file_when_present() { + $dir = $this->make_temp_class_dir(); + $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $module_init->generate_cache( $dir ); + + // Tamper with the cache so we can prove the read path uses it rather than re-discovering. + $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir ); - $this->assertFalse( $method->invoke( $module_init ) ); + $this->assertSame( [ 'TenupTmp\\Sentinel' ], array_values( $read ) ); + + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false in non-production or staging environments. + * With no cache present the runtime discovers live and writes nothing. * * @return void */ - public function test_should_use_cache_returns_false_in_non_production_or_staging_env() { - stubs( - [ - 'wp_get_environment_type' => 'development', - ] + public function test_get_classes_creates_no_cache_when_none_exists() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $classes = $module_init->get_classes( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $classes ); + $this->assertDirectoryDoesNotExist( $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME ); + + $this->remove_temp_dir( $dir ); + } + + /** + * A cache written by an older framework version (a different filename) is ignored, + * so an upgraded site never serves a stale cache it cannot rewrite. + * + * @return void + */ + public function test_get_classes_ignores_legacy_cache_file() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME; + mkdir( $cache_dir ); + + // The previous version wrote `discoverer-cache-{id}` as a serialized file. + $this->write_file( $cache_dir . '/discoverer-cache-TenupFramework', serialize( [ 'TenupTmp\\Legacy' ] ) ); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $read = $module_init->get_classes( $dir ); + + $this->assertNotContains( 'TenupTmp\\Legacy', $read ); + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + + /** + * A corrupt or truncated cache file does not fatal the request: get_classes() catches the + * error and falls back to a live discovery, so a bad cache degrades to uncached rather than + * taking the site down. + * + * @return void + */ + public function test_get_classes_falls_back_to_live_when_cache_is_corrupt() { + $dir = $this->make_temp_class_dir(); + $cache_dir = $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME; + mkdir( $cache_dir ); + + // A truncated / syntactically broken cache file — `require` on this throws a ParseError. + $this->write_file( + $cache_dir . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME, + "getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $read = $module_init->get_classes( $dir ); + + // Fell back to a live scan and still found the real class on disk. + $this->assertContains( 'TenupTmp\\Widget', $read ); - $this->assertFalse( $method->invoke( $module_init ) ); + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns false when TENUP_FRAMEWORK_DISABLE_CLASS_CACHE is defined. + * Defining TENUP_FRAMEWORK_DISABLE_CLASS_CACHE forces live discovery even when a + * cache file is present. * * @return void */ - public function test_should_use_cache_returns_false_when_disable_class_cache_is_defined() { + public function test_disable_constant_forces_live_discovery() { + $dir = $this->make_temp_class_dir(); + + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + + // Tamper with the cache; with caching disabled this sentinel must not be read. + $this->write_file( $this->cache_file_path( $dir ), "get_classes( $dir ); + + $this->assertNotContains( 'TenupTmp\\Sentinel', $read ); + $this->assertContains( 'TenupTmp\\Widget', $read ); + + $this->remove_temp_dir( $dir ); + } + + /** + * In the admin, init_classes() hands a loader record to the debug registry. + * + * @return void + */ + public function test_init_classes_records_a_loader_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertSame( $dir, $loaders[0]['directory'] ); + $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * With no cache present, init_classes() records the time of a genuine live (uncached) + * discovery — a real filesystem scan, so the recorded duration is strictly positive. + * + * @return void + */ + public function test_init_classes_records_live_discovery_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertFalse( $loaders[0]['cache_used'], 'No cache exists, so discovery must be live.' ); + $this->assertIsFloat( $loaders[0]['discovery_seconds'] ); + $this->assertIsFloat( $loaders[0]['lookup_seconds'] ); + // A live filesystem scan and the reflection loop both take measurable time; the never-wired + // default is 0.0, so asserting strictly-positive proves the instrumentation actually ran. + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); + $this->assertGreaterThan( 0.0, $loaders[0]['lookup_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * With a pre-built cache present, init_classes() reads it (cache_used) and still records a + * positive discovery duration — the cache-read cost rather than a live scan. + * + * @return void + */ + public function test_init_classes_records_cache_read_timing_in_admin() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); + $module = \TenupFramework\ModuleInitialization::instance(); + $module->generate_cache( $dir ); + + $module->init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertCount( 1, $loaders ); + $this->assertTrue( $loaders[0]['cache_used'], 'A cache file exists, so it should be used.' ); + $this->assertGreaterThan( 0.0, $loaders[0]['discovery_seconds'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * On the front end, init_classes() records nothing (the data is only viewable in the admin). + * + * @return void + */ + public function test_init_classes_records_nothing_on_the_front_end() { + when( 'is_admin' )->justReturn( false ); + + $dir = $this->make_temp_class_dir(); + + \TenupFramework\ModuleInitialization::instance()->init_classes( $dir ); + + $this->assertSame( [], \TenupFramework\Debug\LoaderDebug::get_loaders() ); + + $this->remove_temp_dir( $dir ); + } + + /** + * discover_live() ignores any cache file and returns the real on-disk classes. + * + * @return void + */ + public function test_discover_live_ignores_the_cache() { + $dir = $this->make_temp_class_dir(); $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $module_init->generate_cache( $dir ); + + // Tamper with the cache; discover_live() must not read it. + $this->write_file( $this->cache_file_path( $dir ), "assertFalse( $method->invoke( $module_init ) ); + $live = $module_init->discover_live( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $live ); + $this->assertNotContains( 'TenupTmp\\Sentinel', $live ); + + $this->remove_temp_dir( $dir ); } /** - * Ensure it returns true under default conditions. + * A corrupt or truncated cache file falls back to a live scan instead of fataling. * * @return void */ - public function test_should_use_cache_returns_true_under_default_conditions() { - stubs( - [ - 'wp_get_environment_type' => 'production', - ] - ); + public function test_get_classes_falls_back_when_the_cache_is_corrupt() { + $dir = $this->make_temp_class_dir(); + $module_init = \TenupFramework\ModuleInitialization::instance(); + $module_init->generate_cache( $dir ); + // Truncated PHP: require() raises a ParseError, which the read path must catch. + $this->write_file( $this->cache_file_path( $dir ), 'get_classes( $dir ); + + $this->assertContains( 'TenupTmp\\Widget', $classes ); + + $this->remove_temp_dir( $dir ); + } + + /** + * When the read path falls back, the loader record flags the failure so the debug page can + * show it rather than reporting the cache as in use. + * + * @return void + */ + public function test_corrupt_cache_records_a_failed_state() { + when( 'is_admin' )->justReturn( true ); + when( 'add_action' )->justReturn( true ); + when( 'add_filter' )->justReturn( true ); + when( 'apply_filters' )->returnArg( 2 ); + + $dir = $this->make_temp_class_dir(); $module_init = \TenupFramework\ModuleInitialization::instance(); - $reflection = new \ReflectionClass( $module_init ); - $method = $reflection->getMethod( 'should_use_cache' ); - $method->setAccessible( true ); + $module_init->generate_cache( $dir ); + $this->write_file( $this->cache_file_path( $dir ), 'init_classes( $dir ); + + $loaders = \TenupFramework\Debug\LoaderDebug::get_loaders(); + $this->assertNotEmpty( $loaders ); + $this->assertTrue( $loaders[0]['cache_failed'] ); + $this->assertFalse( $loaders[0]['cache_used'] ); + $this->assertContains( 'TenupTmp\\Widget', $loaders[0]['classes'] ); + + $this->remove_temp_dir( $dir ); + } + + /** + * Build the absolute path to the cache file for a discovery directory. + * + * @param string $dir The discovery directory. + * + * @return string + */ + private function cache_file_path( string $dir ): string { + return $dir . '/' . \TenupFramework\ModuleInitialization::CACHE_DIR_NAME + . '/' . \TenupFramework\ModuleInitialization::CACHE_FILENAME; + } + + /** + * Create a temporary directory containing a single discoverable class. + * + * @return string The created directory path. + */ + private function make_temp_class_dir(): string { + $dir = sys_get_temp_dir() . '/tenup_framework_test_' . uniqid( '', true ); + mkdir( $dir ); + $this->write_file( $dir . '/Widget.php', "assertNotFalse( file_put_contents( $path, $contents ) ); + } + + /** + * Recursively remove a temporary directory. + * + * @param string $dir The directory to remove. + * + * @return void + */ + private function remove_temp_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } - $this->assertTrue( $method->invoke( $module_init ) ); + rmdir( $dir ); } } diff --git a/tests/TestBlockRegistrar.php b/tests/TestBlockRegistrar.php new file mode 100644 index 0000000..0167912 --- /dev/null +++ b/tests/TestBlockRegistrar.php @@ -0,0 +1,26 @@ +