├── .editorconfig ├── .gitignore ├── LICENSE ├── basic-scaffold.php ├── composer.json ├── composer.lock ├── phpcs.xml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── psalm.xml ├── rector.php ├── src ├── BasicScaffoldPlugin.php ├── BasicScaffoldPluginFactory.php ├── Exception │ ├── BasicScaffoldException.php │ ├── FailedToEscapeValue.php │ ├── FailedToLoadView.php │ ├── FailedToMakeInstance.php │ ├── InvalidArgument.php │ ├── InvalidConfiguration.php │ ├── InvalidContextProperty.php │ ├── InvalidPath.php │ ├── InvalidService.php │ └── Stringify.php ├── Infrastructure │ ├── Activateable.php │ ├── Autoloader.php │ ├── Conditional.php │ ├── Deactivateable.php │ ├── Delayed.php │ ├── HasDependencies.php │ ├── Injector.php │ ├── Injector │ │ ├── InjectionChain.php │ │ └── SimpleInjector.php │ ├── Instantiator.php │ ├── Plugin.php │ ├── Registerable.php │ ├── Renderable.php │ ├── Service.php │ ├── Service │ │ ├── DebugMode.php │ │ └── WordPressDebugMode.php │ ├── ServiceBasedPlugin.php │ ├── ServiceContainer.php │ ├── ServiceContainer │ │ ├── LazilyInstantiatedService.php │ │ └── SimpleServiceContainer.php │ ├── View.php │ ├── View │ │ ├── SimpleView.php │ │ ├── SimpleViewFactory.php │ │ ├── TemplatedView.php │ │ └── TemplatedViewFactory.php │ └── ViewFactory.php └── SampleSubsystem │ ├── SampleBackendService.php │ └── SampleLoopService.php ├── tests └── php │ ├── Fixture │ ├── DummyClass.php │ ├── DummyClassWithDependency.php │ ├── DummyClassWithNamedArguments.php │ ├── DummyInterface.php │ ├── Service │ │ ├── TestCircularA.php │ │ ├── TestCircularB.php │ │ ├── TestDelayedService.php │ │ ├── TestDelayedService1.php │ │ ├── TestDelayedService2.php │ │ ├── TestDependentService.php │ │ ├── TestMultiDependentService.php │ │ ├── TestServiceA.php │ │ ├── TestServiceB.php │ │ ├── TestServiceC.php │ │ └── TestServiceWithMissingDependency.php │ ├── TestCircularDependencyPlugin.php │ ├── TestMissingDependencyPlugin.php │ ├── TestMultipleDelayedDependenciesPlugin.php │ ├── TestServiceBasedPlugin.php │ ├── TestServiceWithMissingDependency.php │ └── views │ │ ├── broken-view.php │ │ ├── child_theme │ │ ├── partial-c.php │ │ └── view-c.php │ │ ├── dynamic-view.php │ │ ├── parent_theme │ │ ├── partial-b.php │ │ ├── partial-c.php │ │ ├── partial-d.php │ │ ├── view-b.php │ │ └── view-c.php │ │ ├── partial.php │ │ ├── plugin │ │ ├── dynamic-view.php │ │ ├── partial-a.php │ │ ├── partial-b.php │ │ ├── partial-c.php │ │ ├── partial-d.php │ │ ├── partial-e.php │ │ ├── partial.php │ │ ├── static-view.php │ │ ├── view-a.php │ │ ├── view-b.php │ │ ├── view-c.php │ │ └── view-with-partial.php │ │ ├── static-view.php │ │ └── view-with-partial.php │ ├── Integration │ ├── SimpleViewFactoryTest.php │ ├── SimpleViewTest.php │ ├── TemplatedViewFactoryTest.php │ ├── TemplatedViewTest.php │ └── TestCase.php │ ├── Unit │ ├── Exception │ │ └── FailedToEscapeValueTest.php │ ├── InjectionChainTest.php │ ├── ServiceBasedPluginTest.php │ ├── SimpleInjectorTest.php │ ├── SimpleViewFactoryTest.php │ ├── SimpleViewTest.php │ ├── TemplatedViewFactoryTest.php │ ├── TemplatedViewTest.php │ └── TestCase.php │ └── ViewHelper.php └── views ├── test-backend-service.php └── test-loop-service.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | trim_trailing_whitespace = true 13 | indent_style = tab 14 | indent_size = 4 15 | 16 | [{src/**/*,config/**/*}] 17 | insert_final_newline = true 18 | 19 | [views/**/*] 20 | insert_final_newline = false 21 | 22 | [tests/php/Fixture/views/**/*] 23 | insert_final_newline = false 24 | 25 | [{.jshintrc,*.json,*.yml}] 26 | indent_style = space 27 | indent_size = 2 28 | insert_final_newline = true 29 | 30 | [{*.txt,wp-config-sample.php}] 31 | end_of_line = crlf 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .deptrac.cache 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019 Alain Schlesser 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /basic-scaffold.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | * 11 | *------------------------------------------------------------------------------ 12 | *-- 1. Provide the plugin meta information that WordPress needs. -- 13 | *------------------------------------------------------------------------------ 14 | * 15 | * @wordpress-plugin 16 | * Plugin Name: MWPD Basic Plugin Scaffold 17 | * Plugin URI: https://www.mwpd.io/ 18 | * Description: Basic plugin scaffold for the "Modern WordPress Plugin Development" book, 19 | * Version: 0.1.0 20 | * Requires PHP: 7.2 21 | * Author: Alain Schlesser 22 | * Author URI: https://www.alainschlesser.com/ 23 | * Text Domain: mwpd-basic 24 | * Domain Path: /languages 25 | * License: MIT 26 | * License URI: https://opensource.org/licenses/MIT 27 | */ 28 | 29 | declare( strict_types=1 ); 30 | 31 | namespace MWPD\BasicScaffold; 32 | 33 | /* 34 | * This is the plugin's bootstrap file. It serves three main purposes: 35 | * 1. Provide the plugin meta information that WordPress needs; 36 | * 2. Prepare the environment so that it is ready to execute our OOP code; 37 | * 3. Instantiate and kick off our "composition root" (our 'Plugin' class). 38 | * 39 | * The bootstrap file should not do anything else, so that we have a clean 40 | * separation between a.) code that needs to be run sequentially and produces 41 | * side-effects and b.) declarations that can be taken out of contexts for 42 | * testing and reuse and have no side-effects. 43 | * 44 | * Anything past this bootstrap file should be autoloadable classes, interfaces 45 | * or traits without any side-effects. 46 | */ 47 | 48 | /* 49 | * As this is the only PHP file having side effects, we need to provide a 50 | * safeguard, so we want to make sure this file is only run from within 51 | * WordPress and cannot be directly called through a web request. 52 | */ 53 | if ( ! defined( 'ABSPATH' ) ) { 54 | die(); 55 | } 56 | 57 | 58 | 59 | /*------------------------------------------------------------------------------ 60 | *-- 2. Prepare the environment so that it is ready to execute our OOP code. -- 61 | *----------------------------------------------------------------------------*/ 62 | 63 | /* 64 | * We try to load the Composer if it exists. 65 | * If it doesn't exist, we fall back to a basic bundled autoloader 66 | * implementation. This allows us to just use the plugin as-is without requirin 67 | * the 'composer install' step. 68 | * Note: If you use Composer not only for autoloading, but also including 69 | * dependencies needed in production, the 'composer install' becomes mandatory 70 | * and the fallback autoloader should probably be removed. 71 | */ 72 | $composer_autoloader = __DIR__ . '/vendor/autoload.php'; 73 | 74 | if ( is_readable ( $composer_autoloader ) ) { 75 | require $composer_autoloader; 76 | } 77 | 78 | if ( ! class_exists( __NAMESPACE__ . '\\PluginFactory' ) ) { 79 | // Composer autoloader apparently was not found, so fall back to our bundled 80 | // autoloader. 81 | require_once __DIR__ . '/src/Infrastructure/Autoloader.php'; 82 | 83 | ( new Infrastructure\Autoloader() ) 84 | ->add_namespace( __NAMESPACE__, __DIR__ . '/src' ) 85 | ->register(); 86 | } 87 | 88 | 89 | 90 | /*------------------------------------------------------------------------------ 91 | *-- 3. Instantiate and kick off our "composition root" (our "Plugin" class). -- 92 | *----------------------------------------------------------------------------*/ 93 | 94 | /* 95 | * We use a factory to instantiate the actual plugin. 96 | * 97 | * The factory keeps the object as a shared instance, so that you can also 98 | * get outside access to that same plugin instance through the factory. 99 | * 100 | * This is similar to a Singleton, but without all the drawbacks the Singleton 101 | * anti-pattern brings along. 102 | * 103 | * For more information on why to avoid a Singleton, 104 | * @see https://www.alainschlesser.com/singletons-shared-instances/ 105 | */ 106 | $plugin = BasicScaffoldPluginFactory::create(); 107 | 108 | /* 109 | * We register activation and deactivation hooks by using closures, as these 110 | * need static access to work correctly. 111 | */ 112 | \register_activation_hook( __FILE__, function () use ( $plugin ) { 113 | $plugin->activate(); 114 | } ); 115 | 116 | \register_deactivation_hook( __FILE__, function () use ( $plugin ) { 117 | $plugin->deactivate(); 118 | } ); 119 | 120 | /* 121 | * Finally, we run the plugin's register method to Hook the plugin into the 122 | * WordPress request lifecycle. 123 | */ 124 | $plugin->register(); 125 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mwpd/basic-scaffold", 3 | "description": "Basic plugin boilerplate code for quick scaffolding.", 4 | "type": "wordpress-plugin", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Alain Schlesser", 9 | "email": "alain.schlesser@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.4 || ^8.0 || ^9.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", 17 | "phpstan/phpstan": "^1 || ^2", 18 | "vimeo/psalm": "^5", 19 | "yoast/phpunit-polyfills": "^3", 20 | "wp-phpunit/wp-phpunit": "^6", 21 | "szepeviktor/phpstan-wordpress": "^1 || ^2", 22 | "php-stubs/wordpress-stubs": "^6", 23 | "squizlabs/php_codesniffer": "^3", 24 | "wp-coding-standards/wpcs": "^3", 25 | "dealerdirect/phpcodesniffer-composer-installer": "^1", 26 | "phpcompatibility/php-compatibility": "^9", 27 | "psalm/plugin-phpunit": "^0.19", 28 | "humanmade/psalm-plugin-wordpress": "^3", 29 | "rector/rector": "^1.2", 30 | "brain/monkey": "^2" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "MWPD\\BasicScaffold\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "MWPD\\BasicScaffold\\Tests\\": "tests/php/" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "phpunit", 44 | "test:unit": "phpunit --testsuite=unit", 45 | "test:integration": "phpunit --testsuite=integration", 46 | "phpstan": "phpstan analyze", 47 | "psalm": "psalm", 48 | "analyze": [ 49 | "@phpstan", 50 | "@psalm" 51 | ], 52 | "phpcs": "phpcs", 53 | "phpcs:fix": "phpcbf", 54 | "lint": [ 55 | "@phpcs", 56 | "@analyze" 57 | ] 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "dealerdirect/phpcodesniffer-composer-installer": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom coding standards for MWPD Basic Scaffold 4 | 5 | 6 | src 7 | tests 8 | views 9 | 10 | 11 | /vendor/* 12 | /node_modules/* 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 0 65 | 66 | 67 | 68 | 69 | 5 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 0 86 | 87 | 88 | 0 89 | 90 | 91 | 92 | 93 | 94 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | /views/* 109 | 110 | 111 | 112 | 113 | /tests/* 114 | /views/* 115 | 116 | 117 | /tests/* 118 | /views/* 119 | 120 | 121 | /tests/* 122 | /views/* 123 | 124 | 125 | /tests/* 126 | /views/* 127 | 128 | 129 | /tests/* 130 | /views/* 131 | 132 | 133 | /tests/php/Fixture/views/* 134 | /views/* 135 | 136 | 137 | /tests/php/Fixture/views/* 138 | /views/* 139 | 140 | 141 | /tests/php/Fixture/views/* 142 | /views/* 143 | 144 | 145 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/szepeviktor/phpstan-wordpress/extension.neon 3 | 4 | parameters: 5 | level: max 6 | treatPhpDocTypesAsCertain: false 7 | paths: 8 | - src 9 | - tests 10 | - views 11 | bootstrapFiles: 12 | - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php 13 | excludePaths: 14 | - tests/php/Fixture/views/broken-view.php 15 | ignoreErrors: 16 | # Ignore errors related to ReflectionClass subtype, see https://github.com/phpstan/phpstan/issues/4078 17 | - '#SimpleInjector::get_dependencies_for\(\) has parameter \$reflection with generic class ReflectionClass but does not specify its types#' 18 | - '#SimpleInjector::ensure_is_instantiable\(\) has parameter \$reflection with generic class ReflectionClass but does not specify its types#' 19 | - '#SimpleInjector::get_class_reflection\(\) return type with generic class ReflectionClass does not specify its types#' 20 | # Ignore errors related to echo and type casting in views. 21 | - '#Parameter \#1 \(mixed\) of echo cannot be converted to string.#' 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests/php/Unit 11 | 12 | 13 | tests/php/Integration 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]) 14 | ->withSkip([ 15 | __DIR__ . '/tests/php/Fixture/views/broken-view.php', 16 | RemoveUnusedVariableAssignRector::class => [ 17 | __DIR__ . '/tests/php/Unit/SimpleViewTest.php', 18 | ], 19 | ]) 20 | ->withIndent(' ', 4) 21 | ->withImportNames(true, true, true, true) 22 | ->withSets([ 23 | SetList::PHP_52, 24 | SetList::PHP_53, 25 | SetList::PHP_54, 26 | SetList::PHP_55, 27 | SetList::PHP_56, 28 | SetList::PHP_70, 29 | SetList::PHP_71, 30 | SetList::PHP_72, 31 | SetList::PHP_73, 32 | SetList::PHP_74, 33 | SetList::CODE_QUALITY, 34 | SetList::STRICT_BOOLEANS, 35 | SetList::PRIVATIZATION, 36 | SetList::CODING_STYLE, 37 | SetList::EARLY_RETURN, 38 | SetList::INSTANCEOF, 39 | SetList::TYPE_DECLARATION, 40 | SetList::DEAD_CODE, 41 | ]); 42 | -------------------------------------------------------------------------------- /src/BasicScaffoldPlugin.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold; 15 | 16 | use MWPD\BasicScaffold\SampleSubsystem\SampleBackendService; 17 | use MWPD\BasicScaffold\SampleSubsystem\SampleLoopService; 18 | use MWPD\BasicScaffold\Infrastructure\{ 19 | ServiceBasedPlugin, 20 | View\TemplatedViewFactory, 21 | ViewFactory 22 | }; 23 | 24 | /** 25 | * The BasicScaffoldPlugin class is the composition root of the plugin. 26 | * 27 | * In here we assemble our infrastructure, configure it for the specific use 28 | * case the plugin is meant to solve and then kick off the services so that they 29 | * can hook themselves into the WordPress lifecycle. 30 | */ 31 | final class BasicScaffoldPlugin extends ServiceBasedPlugin { 32 | /* 33 | * -------------------------------------------------------------------------- 34 | * -- 1. Define the services that make up this plugin. -- 35 | * -------------------------------------------------------------------------- 36 | */ 37 | 38 | /* 39 | * The "plugin" is only a tool to hook arbitrary code up to the WordPress 40 | * execution flow. 41 | * 42 | * The main structure we use to modularize our code is "services". These are 43 | * what makes up the actual plugin, and they provide self-contained pieces 44 | * of code that can work independently. 45 | */ 46 | 47 | /** 48 | * Get the list of services to register. 49 | * 50 | * The services array contains a map of => 51 | * associations. 52 | * 53 | * @return array Associative array of identifiers 54 | * mapped to fully qualified class 55 | * names or callables. 56 | */ 57 | protected function get_service_classes(): array { 58 | return [ 59 | self::SAMPLE_BACKEND_SERVICE_ID => SampleBackendService::class, 60 | self::SAMPLE_LOOP_SERVICE_ID => SampleLoopService::class, 61 | 62 | // Add your service definitions here. 63 | ]; 64 | } 65 | 66 | /* 67 | * -------------------------------------------------------------------------- 68 | * -- 2. Configure the injector so it knows how to assemble them. -- 69 | * -------------------------------------------------------------------------- 70 | */ 71 | 72 | /** 73 | * Get the bindings for the dependency injector. 74 | * 75 | * The bindings array contains a map of => 76 | * mappings, both of which should be fully qualified class names (FQCNs). 77 | * 78 | * The does not need to be the actual PHP `interface` language 79 | * construct, it can be a `class` as well. 80 | * 81 | * Whenever you ask the injector to "make()" an , it will resolve 82 | * these mappings and return an instance of the final it found. 83 | * 84 | * @return array Associative array of 85 | * fully qualified class names 86 | * mapped to fully 87 | * qualified class names or 88 | * callables. 89 | */ 90 | protected function get_bindings(): array { 91 | return [ 92 | // Map the ViewFactory interface to a concrete implementation. 93 | ViewFactory::class => TemplatedViewFactory::class, 94 | 95 | // Add your bindings here. 96 | ]; 97 | } 98 | 99 | /** 100 | * Get the argument bindings for the dependency injector. 101 | * 102 | * The arguments array contains a map of => mappings. 104 | * 105 | * The array is provided in the form => . 106 | * 107 | * @return array> Associative array of arrays mapping 108 | * argument names to argument values. 109 | */ 110 | protected function get_arguments(): array { 111 | return [ 112 | 113 | /* 114 | * Example - add a scalar value to an argument for SampleService: 115 | * SampleService::class => [ 'argument_name' => 'value' ], 116 | */ 117 | 118 | // Add your argument mappings here. 119 | ]; 120 | } 121 | 122 | /** 123 | * Get the shared instances for the dependency injector. 124 | * 125 | * The shared instances array contains a list of FQCNs that are meant to be 126 | * reused. For multiple "make()" requests, the injector will return the same 127 | * instance reference for these, instead of always returning a new one. 128 | * 129 | * This effectively turns these FQCNs into a "singleton", without incurring 130 | * all the drawbacks of the Singleton design anti-pattern. 131 | * 132 | * @return array Array of fully qualified class names. 133 | */ 134 | protected function get_shared_instances(): array { 135 | return [ 136 | 137 | /* 138 | * Example - make SampleService be shared amongst instantiations: 139 | * SampleService::class, 140 | */ 141 | 142 | // Add your shared instances here. 143 | ]; 144 | } 145 | 146 | /** 147 | * Get the delegations for the dependency injector. 148 | * 149 | * The delegations array contains a map of => 150 | * mappings. 151 | * 152 | * The is basically a factory to provide custom instantiation 153 | * logic for the given . 154 | * 155 | * @return array Associative array of callables. 156 | */ 157 | protected function get_delegations(): array { 158 | return [ 159 | 160 | /* 161 | * Add a factory for instantiating WP_Post objects: 162 | * \WP_Post::class => function () { 163 | * return \get_post( \get_the_ID() ); }, 164 | */ 165 | 166 | // Add your delegations here. 167 | ]; 168 | } 169 | 170 | /* 171 | * -------------------------------------------------------------------------- 172 | * -- 3. Define prefixes and identifiers for outside access. -- 173 | * -------------------------------------------------------------------------- 174 | */ 175 | 176 | /* 177 | * Prefixes to use. 178 | * 179 | * These are provided so that if multiple plugins use the same boilerplate 180 | * code, there hooks and service identifiers are scoped and don't clash. 181 | */ 182 | protected const HOOK_PREFIX = 'mwpd.basic_scaffold.'; 183 | 184 | protected const SERVICE_PREFIX = 'mwpd.basic_scaffold.'; 185 | 186 | /* 187 | * Service identifiers we know about. 188 | * 189 | * These can be used from outside code as well to directly refer to a 190 | * service when talking to the service container. 191 | */ 192 | public const VIEW_FACTORY_ID = self::SERVICE_PREFIX . 'view-factory'; 193 | 194 | public const SAMPLE_BACKEND_SERVICE_ID = self::SERVICE_PREFIX . 'sample-subsystem.sample-backend-service'; 195 | 196 | public const SAMPLE_LOOP_SERVICE_ID = self::SERVICE_PREFIX . 'sample-subsystem.sample-loop-service'; 197 | } 198 | -------------------------------------------------------------------------------- /src/BasicScaffoldPluginFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\Plugin; 17 | 18 | /** 19 | * The plugin factory is responsible for instantiating the plugin and returning 20 | * that instance. 21 | * 22 | * It can decide whether to return a shared or a fresh instance as needed. 23 | * 24 | * To read more about why this is preferable to a Singleton, 25 | * 26 | * @see https://www.alainschlesser.com/singletons-shared-instances/ 27 | */ 28 | final class BasicScaffoldPluginFactory { 29 | 30 | /** 31 | * Create and return an instance of the plugin. 32 | * 33 | * This always returns a shared instance. This way, outside code can always 34 | * get access to the object instance of the plugin. 35 | * 36 | * @return Plugin Plugin instance. 37 | */ 38 | public static function create(): Plugin { 39 | /** 40 | * We use a static variable to ensure that the plugin is only instantiated 41 | * once. This is important for performance reasons and to ensure that the 42 | * plugin is properly initialized. 43 | * 44 | * This serves the same purpose as a Singleton, but it is implemented as 45 | * a factory to stick to SOLID principles. 46 | * 47 | * @var Plugin|null $plugin 48 | */ 49 | static $plugin = null; 50 | 51 | if ( null === $plugin ) { 52 | $plugin = new BasicScaffoldPlugin(); 53 | } 54 | 55 | return $plugin; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Exception/BasicScaffoldException.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use Throwable; 17 | 18 | /** 19 | * This is a "marker interface" to mark all the exception that come with this 20 | * plugin with this one interface. 21 | * 22 | * This allows you to not only catch individual exceptions, but also catch "all 23 | * exceptions from plugin XY". 24 | */ 25 | interface BasicScaffoldException extends Throwable { 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/FailedToEscapeValue.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use RuntimeException; 17 | 18 | /** 19 | * Exception thrown when a value cannot be escaped. 20 | */ 21 | final class FailedToEscapeValue extends RuntimeException implements BasicScaffoldException { 22 | 23 | use Stringify; 24 | 25 | /** 26 | * Create a new instance of the exception if the value itself created 27 | * an exception. 28 | * 29 | * @param mixed $value Value that could not be escaped. 30 | */ 31 | public static function from_value( $value ): self { 32 | $message = \sprintf( 33 | 'Could not escape the value "%1$s".', 34 | self::stringify( $value ) 35 | ); 36 | 37 | return new self( $message ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exception/FailedToLoadView.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use RuntimeException; 17 | use Throwable; 18 | 19 | /** 20 | * Exception thrown when a view file cannot be loaded. 21 | */ 22 | final class FailedToLoadView extends RuntimeException implements BasicScaffoldException { 23 | 24 | /** 25 | * Create a new instance of the exception if the view file itself created 26 | * an exception. 27 | * 28 | * @param string $uri URI of the file that is not accessible or 29 | * not readable. 30 | * @param Throwable $exception Exception that was thrown by the view file. 31 | */ 32 | public static function from_view_exception( $uri, $exception ): self { 33 | $message = \sprintf( 34 | 'Could not load the View URI "%1$s". Reason: "%2$s".', 35 | $uri, 36 | $exception->getMessage() 37 | ); 38 | 39 | return new self( $message, (int) $exception->getCode(), $exception ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Exception/FailedToMakeInstance.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use RuntimeException; 17 | 18 | /** 19 | * Exception thrown when a class instance cannot be made. 20 | */ 21 | final class FailedToMakeInstance extends RuntimeException implements BasicScaffoldException { 22 | 23 | // These constants are public so you can use them to find out what exactly 24 | // happened when you catch a "FailedToMakeInstance" exception. 25 | public const CIRCULAR_REFERENCE = 100; 26 | 27 | public const UNRESOLVED_INTERFACE = 200; 28 | 29 | public const UNREFLECTABLE_CLASS = 300; 30 | 31 | public const UNRESOLVED_ARGUMENT = 400; 32 | 33 | public const UNINSTANTIATED_SHARED_INSTANCE = 500; 34 | 35 | public const INVALID_DELEGATE = 600; 36 | 37 | public const INVALID_REFLECTION = 700; 38 | 39 | /** 40 | * Create a new instance of the exception for an interface or class that 41 | * created a circular reference. 42 | * 43 | * @param string $interface_or_class Interface or class name that generated 44 | * the circular reference. 45 | */ 46 | public static function for_circular_reference( string $interface_or_class ): self { 47 | $message = \sprintf( 48 | 'Circular reference detected while trying to resolve the interface or class "%s".', 49 | $interface_or_class 50 | ); 51 | 52 | return new self( $message, self::CIRCULAR_REFERENCE ); 53 | } 54 | 55 | /** 56 | * Create a new instance of the exception for an interface that could not 57 | * be resolved to an instantiable class. 58 | * 59 | * @param string $interface_name Interface that was left unresolved. 60 | */ 61 | public static function for_unresolved_interface( string $interface_name ): self { 62 | $message = \sprintf( 63 | 'Could not resolve the interface "%s" to an instantiable class, probably forgot to bind an implementation.', 64 | $interface_name 65 | ); 66 | 67 | return new self( $message, self::UNRESOLVED_INTERFACE ); 68 | } 69 | 70 | /** 71 | * Create a new instance of the exception for an interface or class that 72 | * could not be reflected upon. 73 | * 74 | * @param string $interface_or_class Interface or class that could not be 75 | * reflected upon. 76 | */ 77 | public static function for_unreflectable_class( string $interface_or_class ): self { 78 | $message = \sprintf( 79 | 'Could not reflect on the interface or class "%s", probably not a valid FQCN.', 80 | $interface_or_class 81 | ); 82 | 83 | return new self( $message, self::UNREFLECTABLE_CLASS ); 84 | } 85 | 86 | /** 87 | * Create a new instance of the exception for an argument that could not be 88 | * resolved. 89 | * 90 | * @param string $argument_name Name of the argument that could not be 91 | * resolved. 92 | * @param string $class_name Class that had the argument in its 93 | * constructor. 94 | */ 95 | public static function for_unresolved_argument( string $argument_name, string $class_name ): self { 96 | $message = \sprintf( 97 | 'Could not resolve the argument "%s" while trying to instantiate the class "%s".', 98 | $argument_name, 99 | $class_name 100 | ); 101 | 102 | return new self( $message, self::UNRESOLVED_ARGUMENT ); 103 | } 104 | 105 | /** 106 | * Create a new instance of the exception for a class that was meant to be 107 | * reused but was not yet instantiated. 108 | * 109 | * @param string $class_name Class that was not yet instantiated. 110 | */ 111 | public static function for_uninstantiated_shared_instance( string $class_name ): self { 112 | $message = \sprintf( 113 | 'Could not retrieve the shared instance for "%s" as it was not instantiated yet.', 114 | $class_name 115 | ); 116 | 117 | return new self( $message, self::UNINSTANTIATED_SHARED_INSTANCE ); 118 | } 119 | 120 | /** 121 | * Create a new instance of the exception for a delegate that was requested 122 | * for a class that doesn't have one. 123 | * 124 | * @param string $class_name Class for which there is no delegate. 125 | */ 126 | public static function for_invalid_delegate( string $class_name ): self { 127 | $message = \sprintf( 128 | 'Could not retrieve a delegate for "%s", none was defined.', 129 | $class_name 130 | ); 131 | 132 | return new self( $message, self::INVALID_DELEGATE ); 133 | } 134 | 135 | /** 136 | * Create a new instance of the exception for a reflection that is not valid. 137 | * 138 | * @param string $class_name Class that was reflected upon. 139 | */ 140 | public static function for_invalid_reflection( string $class_name ): self { 141 | $message = \sprintf( 142 | 'Could not create a reflection for the class "%s".', 143 | $class_name 144 | ); 145 | 146 | return new self( $message, self::INVALID_REFLECTION ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgument.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | /** 19 | * Exception thrown when a service is invalid. 20 | */ 21 | final class InvalidArgument extends InvalidArgumentException implements BasicScaffoldException { 22 | 23 | use Stringify; 24 | 25 | /** 26 | * Create a new instance of the exception for a service class name that is 27 | * not recognized. 28 | * 29 | * @param mixed $name Name of the argument that was not recognized. 30 | */ 31 | public static function from_name( $name ): self { 32 | $message = \sprintf( 33 | 'The argument "%s" is not recognized and cannot be registered.', 34 | self::stringify( $name ) 35 | ); 36 | 37 | return new self( $message ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exception/InvalidConfiguration.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | /** 19 | * Exception thrown when a configuration is invalid. 20 | */ 21 | final class InvalidConfiguration extends InvalidArgumentException implements BasicScaffoldException { 22 | 23 | use Stringify; 24 | 25 | /** 26 | * Create a new instance of the exception for an invalid bindings array. 27 | * 28 | * @param mixed $bindings Bindings that are not an array. 29 | */ 30 | public static function from_invalid_bindings( $bindings ): self { 31 | $message = \sprintf( 32 | 'The bindings configuration "%s" is not an array and cannot be registered.', 33 | self::stringify( $bindings ) 34 | ); 35 | 36 | return new self( $message ); 37 | } 38 | 39 | /** 40 | * Create a new instance of the exception for an invalid arguments array. 41 | * 42 | * @param mixed $arguments Arguments that are not an array. 43 | */ 44 | public static function from_invalid_arguments( $arguments ): self { 45 | $message = \sprintf( 46 | 'The arguments configuration "%s" is not an array and cannot be registered.', 47 | self::stringify( $arguments ) 48 | ); 49 | 50 | return new self( $message ); 51 | } 52 | 53 | /** 54 | * Create a new instance of the exception for an invalid shared instances array. 55 | * 56 | * @param mixed $shared_instances Shared instances that are not an array. 57 | */ 58 | public static function from_invalid_shared_instances( $shared_instances ): self { 59 | $message = \sprintf( 60 | 'The shared instances configuration "%s" is not an array and cannot be registered.', 61 | self::stringify( $shared_instances ) 62 | ); 63 | 64 | return new self( $message ); 65 | } 66 | 67 | /** 68 | * Create a new instance of the exception for an invalid delegations array. 69 | * 70 | * @param mixed $delegations Delegations that are not an array. 71 | */ 72 | public static function from_invalid_delegations( $delegations ): self { 73 | $message = \sprintf( 74 | 'The delegations configuration "%s" is not an array and cannot be registered.', 75 | self::stringify( $delegations ) 76 | ); 77 | 78 | return new self( $message ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Exception/InvalidContextProperty.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | /** 19 | * Exception for invalid context properties. 20 | */ 21 | final class InvalidContextProperty extends InvalidArgumentException implements BasicScaffoldException { 22 | 23 | /** 24 | * Create a new instance of the exception for a context property that is 25 | * not recognized. 26 | * 27 | * @param string $property Name of the context property that was not 28 | * recognized. 29 | */ 30 | public static function from_property( string $property ): self { 31 | $message = \sprintf( 32 | 'The property "%s" could not be found within the context of the currently rendered view.', 33 | $property 34 | ); 35 | 36 | return new self( $message ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exception/InvalidPath.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | /** 19 | * Exception for invalid paths. 20 | */ 21 | final class InvalidPath extends InvalidArgumentException implements BasicScaffoldException { 22 | 23 | /** 24 | * Create a new instance of the exception for a file that is not accessible 25 | * or not readable. 26 | * 27 | * @param string $path Path of the file that is not accessible or not 28 | * readable. 29 | */ 30 | public static function from_path( $path ): self { 31 | $message = \sprintf( 32 | 'The view path "%s" is not accessible or readable.', 33 | $path 34 | ); 35 | 36 | return new self( $message ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exception/InvalidService.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | /** 19 | * Exception thrown when a service is invalid. 20 | */ 21 | final class InvalidService extends InvalidArgumentException implements BasicScaffoldException { 22 | 23 | use Stringify; 24 | 25 | /** 26 | * Create a new instance of the exception for a service class name that is 27 | * not recognized. 28 | * 29 | * @param string|object $service Class name of the service that was not 30 | * recognized. 31 | */ 32 | public static function from_service( $service ): self { 33 | $message = \sprintf( 34 | 'The service "%s" is not recognized and cannot be registered.', 35 | self::stringify( $service ) 36 | ); 37 | 38 | return new self( $message ); 39 | } 40 | 41 | /** 42 | * Create a new instance of the exception for a service identifier that is 43 | * not recognized. 44 | * 45 | * @param string $service_id Identifier of the service that is not being 46 | * recognized. 47 | */ 48 | public static function from_service_id( string $service_id ): self { 49 | $message = \sprintf( 50 | 'The service ID "%s" is not recognized and cannot be retrieved.', 51 | $service_id 52 | ); 53 | 54 | return new self( $message ); 55 | } 56 | 57 | /** 58 | * Create a new instance of the exception for an invalid class name. 59 | * 60 | * @param mixed $class_name Class name that is not a string. 61 | */ 62 | public static function from_invalid_class_name( $class_name ): self { 63 | $message = \sprintf( 64 | 'The class name "%s" is not a string and cannot be registered as a service.', 65 | self::stringify( $class_name ) 66 | ); 67 | 68 | return new self( $message ); 69 | } 70 | 71 | /** 72 | * Create a new instance of the exception for an invalid identifier. 73 | * 74 | * @param mixed $identifier Identifier that is not a string. 75 | */ 76 | public static function from_invalid_identifier( $identifier ): self { 77 | $message = \sprintf( 78 | 'The identifier "%s" is not a string and cannot be registered as a service.', 79 | self::stringify( $identifier ) 80 | ); 81 | 82 | return new self( $message ); 83 | } 84 | 85 | /** 86 | * Create a new instance of the exception for an invalid delegation. 87 | * 88 | * @param string $class_name Class name that is not callable. 89 | * @param mixed $delegation Delegation that is not callable. 90 | */ 91 | public static function from_invalid_delegation( string $class_name, $delegation ): self { 92 | $message = \sprintf( 93 | 'The delegation for "%s" is not a callable: %s', 94 | self::stringify( $class_name ), 95 | self::stringify( $delegation ) 96 | ); 97 | 98 | return new self( $message ); 99 | } 100 | 101 | /** 102 | * Create a new instance of the exception for an invalid argument map. 103 | * 104 | * @param string $class_name Class name that is not an array. 105 | * @param mixed $argument_map Argument map that is not an array. 106 | */ 107 | public static function from_invalid_argument_map( string $class_name, $argument_map ): self { 108 | $message = \sprintf( 109 | 'The argument map for "%s" is not an array: %s', 110 | self::stringify( $class_name ), 111 | self::stringify( $argument_map ) 112 | ); 113 | 114 | return new self( $message ); 115 | } 116 | 117 | /** 118 | * Create a new instance of the exception for a lazy service. 119 | * 120 | * @param mixed $service Service that is not an object of type Service. 121 | */ 122 | public static function from_lazy_service( $service ): self { 123 | $message = \sprintf( 124 | 'The lazy service "%s" cannot be instantiated into an object of type Service.', 125 | self::stringify( $service ) 126 | ); 127 | 128 | return new self( $message ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Exception/Stringify.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Exception; 15 | 16 | /** 17 | * Trait to add stringification functionality to exceptions. 18 | */ 19 | trait Stringify { 20 | 21 | /** 22 | * Stringify a value. 23 | * 24 | * @param mixed $value Value to stringify. 25 | * @return string Stringified value. 26 | */ 27 | private static function stringify( $value ): string { 28 | if ( \is_object( $value ) && \method_exists( $value, '__toString' ) ) { 29 | return (string) $value; 30 | } 31 | 32 | if ( \is_scalar( $value ) ) { 33 | return (string) $value; 34 | } 35 | 36 | return '{' . \gettype( $value ) . '}'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Infrastructure/Activateable.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that can be activated. 18 | * 19 | * By tagging a service with this interface, the system will automatically hook 20 | * it up to the WordPress activation hook. 21 | * 22 | * This way, we can just add the simple interface marker and not worry about how 23 | * to wire up the code to reach that part during the static activation hook. 24 | */ 25 | interface Activateable { 26 | 27 | /** 28 | * Activate the service. 29 | */ 30 | public function activate(): void; 31 | } 32 | -------------------------------------------------------------------------------- /src/Infrastructure/Autoloader.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | use Exception; 17 | 18 | /** 19 | * Bundled fallback autoloader. 20 | * 21 | * Ideally, you would opt to fully embrace Composer and not need this at all. 22 | * 23 | * WordPress being far from ideal, though, it makes sense to include this for 24 | * the average plugin. 25 | * 26 | * @phpstan-type AutoloaderNamespace array{ 27 | * root: string, 28 | * base_dir: string, 29 | * prefix: string, 30 | * suffix: string, 31 | * lowercase: bool, 32 | * underscores: bool, 33 | * } 34 | */ 35 | final class Autoloader { 36 | 37 | 38 | 39 | private const ROOT = 'root'; 40 | 41 | private const BASE_DIR = 'base_dir'; 42 | 43 | private const PREFIX = 'prefix'; 44 | 45 | private const SUFFIX = 'suffix'; 46 | 47 | private const LOWERCASE = 'lowercase'; 48 | 49 | private const UNDERSCORES = 'underscores'; 50 | 51 | private const DEFAULT_PREFIX = ''; 52 | 53 | private const DEFAULT_SUFFIX = '.php'; 54 | 55 | private const AUTOLOAD_METHOD = 'autoload'; 56 | 57 | /** 58 | * Array containing the registered namespace structures. 59 | * 60 | * @var array 61 | */ 62 | private array $namespaces = []; 63 | 64 | /** 65 | * Destructor for the Autoloader class. 66 | * 67 | * The destructor automatically unregisters the autoload callback function 68 | * with the SPL autoload system. 69 | * 70 | * @return void 71 | */ 72 | public function __destruct() { 73 | $this->unregister(); 74 | } 75 | 76 | /** 77 | * Registers the autoload callback with the SPL autoload system. 78 | * 79 | * @throws Exception If the autoloader could not be registered. 80 | */ 81 | public function register(): void { 82 | \spl_autoload_register( [ $this, self::AUTOLOAD_METHOD ] ); 83 | } 84 | 85 | /** 86 | * Unregisters the autoload callback with the SPL autoload system. 87 | */ 88 | public function unregister(): void { 89 | \spl_autoload_unregister( [ $this, self::AUTOLOAD_METHOD ] ); 90 | } 91 | 92 | /** 93 | * Add a specific namespace structure with our custom autoloader. 94 | * 95 | * @param string $root Root namespace name. 96 | * @param string $base_dir Directory containing the class files. 97 | * @param string $prefix Optional. Prefix to be added before the 98 | * class. Defaults to an empty string. 99 | * @param string $suffix Optional. Suffix to be added after the 100 | * class. Defaults to '.php'. 101 | * @param boolean $lowercase Optional. Whether the class should be 102 | * changed to lowercase. Defaults to false. 103 | * @param boolean $underscores Optional. Whether the underscores should be 104 | * changed to hyphens. Defaults to false. 105 | */ 106 | public function add_namespace( 107 | string $root, 108 | string $base_dir, 109 | string $prefix = self::DEFAULT_PREFIX, 110 | string $suffix = self::DEFAULT_SUFFIX, 111 | bool $lowercase = false, 112 | bool $underscores = false 113 | ): self { 114 | $this->namespaces[] = [ 115 | self::ROOT => $this->normalize_root( $root ), 116 | self::BASE_DIR => $this->ensure_trailing_slash( $base_dir ), 117 | self::PREFIX => $prefix, 118 | self::SUFFIX => $suffix, 119 | self::LOWERCASE => $lowercase, 120 | self::UNDERSCORES => $underscores, 121 | ]; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * The autoload function that gets registered with the SPL Autoloader 128 | * system. 129 | * 130 | * @param string $class_string The class that got requested by the spl_autoloader. 131 | */ 132 | public function autoload( string $class_string ): void { 133 | 134 | // Iterate over namespaces to find a match. 135 | foreach ( $this->namespaces as $namespace ) { 136 | 137 | // Move on if the object does not belong to the current namespace. 138 | if ( 0 !== \strpos( $class_string, $namespace[ self::ROOT ] ) ) { 139 | continue; 140 | } 141 | 142 | // Remove namespace root level to correspond with root filesystem. 143 | $filename = \str_replace( 144 | $namespace[ self::ROOT ], 145 | '', 146 | $class_string 147 | ); 148 | 149 | // Remove a leading backslash from the class name. 150 | $filename = $this->remove_leading_backslash( $filename ); 151 | 152 | // Replace the namespace separator "\" by the system-dependent 153 | // directory separator. 154 | $filename = \str_replace( 155 | '\\', 156 | DIRECTORY_SEPARATOR, 157 | $filename 158 | ); 159 | 160 | // Change to lower case if requested. 161 | if ( true === $namespace[ self::LOWERCASE ] ) { 162 | $filename = \strtolower( $filename ); 163 | } 164 | 165 | // Change underscores into hyphens if requested. 166 | if ( true === $namespace[ self::UNDERSCORES ] ) { 167 | $filename = \str_replace( '_', '-', $filename ); 168 | } 169 | 170 | // Add base_dir, prefix and suffix. 171 | $filepath = $namespace[ self::BASE_DIR ] 172 | . $namespace[ self::PREFIX ] 173 | . $filename 174 | . $namespace[ self::SUFFIX ]; 175 | 176 | // Require the file if it exists and is readable. 177 | if ( \is_readable( $filepath ) ) { 178 | /** 179 | * This include cannot be followed to be statically analyzed. 180 | * 181 | * @psalm-suppress UnresolvableInclude 182 | */ 183 | require $filepath; 184 | } 185 | } 186 | } 187 | 188 | /** 189 | * Normalize a namespace root. 190 | * 191 | * @param string $root Namespace root that needs to be normalized. 192 | * 193 | * @return string Normalized namespace root. 194 | */ 195 | private function normalize_root( string $root ): string { 196 | $root = $this->remove_leading_backslash( $root ); 197 | 198 | return $this->ensure_trailing_backslash( $root ); 199 | } 200 | 201 | /** 202 | * Remove a leading backslash from a namespace. 203 | * 204 | * @param string $namespace_string Namespace to remove the leading backslash from. 205 | * 206 | * @return string Modified namespace. 207 | */ 208 | private function remove_leading_backslash( string $namespace_string ): string { 209 | return \ltrim( $namespace_string, '\\' ); 210 | } 211 | 212 | /** 213 | * Make sure a namespace ends with a trailing backslash. 214 | * 215 | * @param string $namespace_string Namespace to check the trailing backslash of. 216 | * 217 | * @return string Modified namespace. 218 | */ 219 | private function ensure_trailing_backslash( string $namespace_string ): string { 220 | return \rtrim( $namespace_string, '\\' ) . '\\'; 221 | } 222 | 223 | /** 224 | * Make sure a path ends with a trailing slash. 225 | * 226 | * @param string $path Path to check the trailing slash of. 227 | * 228 | * @return string Modified path. 229 | */ 230 | private function ensure_trailing_slash( string $path ): string { 231 | return \rtrim( $path, '/' ) . '/'; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Infrastructure/Conditional.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that can be instantiated conditionally. 18 | * 19 | * A class marked as being conditionally can be asked whether it should be 20 | * instantiated through a static method. An example would be a service that is 21 | * only available on the admin backend. 22 | * 23 | * This allows for a more systematic and automated optimization of how the 24 | * different parts of the plugin are enabled or disabled. 25 | */ 26 | interface Conditional { 27 | 28 | /** 29 | * Check whether the conditional object is currently needed. 30 | * 31 | * @return bool Whether the conditional object is needed. 32 | */ 33 | public static function is_needed(): bool; 34 | } 35 | -------------------------------------------------------------------------------- /src/Infrastructure/Deactivateable.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that can be deactivated. 18 | * 19 | * By tagging a service with this interface, the system will automatically hook 20 | * it up to the WordPress deactivation hook. 21 | * 22 | * This way, we can just add the simple interface marker and not worry about how 23 | * to wire up the code to reach that part during the static deactivation hook. 24 | */ 25 | interface Deactivateable { 26 | 27 | /** 28 | * Deactivate the service. 29 | */ 30 | public function deactivate(): void; 31 | } 32 | -------------------------------------------------------------------------------- /src/Infrastructure/Delayed.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that is delayed to a later point in the execution flow. 18 | * 19 | * A class marked as being delayed can return the action at which it requires 20 | * to be registered. 21 | * 22 | * This can be used to only register a given object after certain contextual 23 | * requirements are met, like registering a frontend rendering service only 24 | * after the loop has been set up. 25 | */ 26 | interface Delayed { 27 | 28 | /** 29 | * Get the action to use for registering the service. 30 | * 31 | * @return non-empty-string Registration action to use. 32 | */ 33 | public static function get_registration_action(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/Infrastructure/HasDependencies.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Interface for services that depend on other services. 18 | */ 19 | interface HasDependencies { 20 | 21 | /** 22 | * Get the list of service IDs this service depends on. 23 | * 24 | * @return string[] List of service IDs. 25 | */ 26 | public static function get_dependencies(): array; 27 | } 28 | -------------------------------------------------------------------------------- /src/Infrastructure/Injector.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * The dependency injector should be the only piece of code doing actual 18 | * instantiations, with the following exceptions: 19 | * - Factories can instantiate directly. 20 | * - Value objects should be instantiated directly where they are being used. 21 | * 22 | * Through technical features like "binding" interfaces to classes or 23 | * "auto-wiring" to resolve all dependency of a class to be instantiated 24 | * automatically, the dependency injector allows for the largest part of the 25 | * code to adhere to the "Code against Interfaces, not Implementations" 26 | * principle. 27 | * 28 | * Finally, the dependency injector should be the only one to decide what 29 | * objects to "share" (always handing out the same instance) or not to share 30 | * (always returning a fresh new instance on each subsequent call). This 31 | * effectively gets rid of the dreaded Singletons. 32 | */ 33 | interface Injector extends Service { 34 | 35 | /** 36 | * Make an object instance out of an interface or class. 37 | * 38 | * @param class-string $interface_or_class Interface or class to make an object 39 | * instance out of. 40 | * @param array $arguments Optional. Additional arguments to pass 41 | * to the constructor. Defaults to an 42 | * empty array. 43 | * @return object Instantiated object. 44 | */ 45 | public function make( string $interface_or_class, array $arguments = [] ): object; 46 | 47 | /** 48 | * Bind a given interface or class to an implementation. 49 | * 50 | * Note: The implementation can be an interface as well, as long as it can 51 | * be resolved to an instantiatable class at runtime. 52 | * 53 | * @param class-string $from Interface or class to bind an implementation to. 54 | * @param class-string $to Interface or class that provides the implementation. 55 | */ 56 | public function bind( string $from, string $to ): Injector; 57 | 58 | /** 59 | * Bind an argument for a class to a specific value. 60 | * 61 | * @param class-string $interface_or_class Interface or class to bind an argument 62 | * for. 63 | * @param string $argument_name Argument name to bind a value to. 64 | * @param mixed $value Value to bind the argument to. 65 | */ 66 | public function bind_argument( 67 | string $interface_or_class, 68 | string $argument_name, 69 | $value 70 | ): Injector; 71 | 72 | /** 73 | * Always reuse and share the same instance for the provided interface or 74 | * class. 75 | * 76 | * @param class-string $interface_or_class Interface or class to reuse. 77 | */ 78 | public function share( string $interface_or_class ): Injector; 79 | 80 | /** 81 | * Delegate instantiation of an interface or class to a callable. 82 | * 83 | * @param class-string $interface_or_class Interface or class to delegate the 84 | * instantiation of. 85 | * @param callable $delegation Callable to use for instantiation. 86 | */ 87 | public function delegate( string $interface_or_class, callable $delegation ): Injector; 88 | } 89 | -------------------------------------------------------------------------------- /src/Infrastructure/Injector/InjectionChain.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\Injector; 15 | 16 | use LogicException; 17 | 18 | /** 19 | * The injection chain is similar to a trace, keeping track of what we have done 20 | * so far and at what depth within the auto-wiring we currently are. 21 | * 22 | * It is used to detect circular dependencies, and can also be dumped for 23 | * debugging information. 24 | */ 25 | final class InjectionChain { 26 | 27 | /** 28 | * Chain of injections. 29 | * 30 | * @var array 31 | */ 32 | private array $chain = []; 33 | 34 | /** 35 | * Resolutions. 36 | * 37 | * @var array 38 | */ 39 | private array $resolutions = []; 40 | 41 | /** 42 | * Add class to injection chain. 43 | * 44 | * @param string $class_name Class to add to injection chain. 45 | * @return self Modified injection chain. 46 | */ 47 | public function add_to_chain( string $class_name ): self { 48 | $this->chain[] = $class_name; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Add resolution for circular reference detection. 55 | * 56 | * @param string $resolution Resolution to add. 57 | * @return self Modified injection chain. 58 | */ 59 | public function add_resolution( string $resolution ): self { 60 | $this->resolutions[ $resolution ] = true; 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * Get the last class that was pushed to the injection chain. 67 | * 68 | * @return string Last class pushed to the injection chain. 69 | * @throws LogicException If the chain is empty. 70 | * @phpstan-return class-string 71 | */ 72 | public function get_class(): string { 73 | if ( $this->chain === [] ) { 74 | throw new LogicException( 75 | 'Access to injection chain before any resolution was made.' 76 | ); 77 | } 78 | 79 | /** 80 | * This returns a class string. 81 | * 82 | * @phpstan-var class-string 83 | */ 84 | return \end( $this->chain ) ?: ''; 85 | } 86 | 87 | /** 88 | * Get the injection chain. 89 | * 90 | * @return array Chain of injections. 91 | */ 92 | public function get_chain(): array { 93 | return \array_reverse( $this->chain ); 94 | } 95 | 96 | /** 97 | * Check whether the injection chain already has a given resolution. 98 | * 99 | * @param string $resolution Resolution to check for. 100 | * @return bool Whether the resolution was found. 101 | */ 102 | public function has_resolution( string $resolution ): bool { 103 | return \array_key_exists( $resolution, $this->resolutions ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Infrastructure/Injector/SimpleInjector.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\Injector; 15 | 16 | use MWPD\BasicScaffold\Exception\FailedToMakeInstance; 17 | use MWPD\BasicScaffold\Infrastructure\Injector; 18 | use MWPD\BasicScaffold\Infrastructure\Instantiator; 19 | use ReflectionClass; 20 | use ReflectionNamedType; 21 | use ReflectionParameter; 22 | use ReflectionType; 23 | use Throwable; 24 | 25 | /** 26 | * A simplified implementation of a dependency injector. 27 | */ 28 | final class SimpleInjector implements Injector { 29 | 30 | /** 31 | * Special-case index key for handling globally defined named arguments. 32 | * 33 | * This is typed as a class-string to ensure that it fits the type requirements. 34 | * 35 | * @var string 36 | */ 37 | public const GLOBAL_ARGUMENTS = '__global__'; 38 | 39 | /** 40 | * Mapping of interfaces to classes. 41 | * 42 | * @var array 43 | */ 44 | private array $mappings = []; 45 | 46 | /** 47 | * Mapping of shared instances. 48 | * 49 | * @var array 50 | */ 51 | private array $shared_instances = []; 52 | 53 | /** 54 | * Mapping of delegates. 55 | * 56 | * @var array 57 | */ 58 | private array $delegates = []; 59 | 60 | /** 61 | * Mapping of argument names to values. 62 | * 63 | * @var array> 64 | */ 65 | private array $argument_mappings = [ 66 | self::GLOBAL_ARGUMENTS => [], 67 | ]; 68 | 69 | /** 70 | * Instantiator to use. 71 | */ 72 | private Instantiator $instantiator; 73 | 74 | /** 75 | * Instantiate a SimpleInjector object. 76 | * 77 | * @param Instantiator|null $instantiator Optional. Instantiator to use. 78 | */ 79 | public function __construct( ?Instantiator $instantiator = null ) { 80 | $this->instantiator = $instantiator ?? $this->get_fallback_instantiator(); 81 | } 82 | 83 | /** 84 | * Make an object instance out of an interface or class. 85 | * 86 | * @param class-string $interface_or_class Interface or class to make an object 87 | * instance out of. 88 | * @param array $arguments Optional. Additional arguments 89 | * to pass to the constructor. 90 | * Defaults to an empty array. 91 | * @return object Instantiated object. 92 | * @throws FailedToMakeInstance If the object could not be instantiated. 93 | */ 94 | public function make( string $interface_or_class, array $arguments = [] ): object { 95 | $injection_chain = $this->resolve( 96 | new InjectionChain(), 97 | $interface_or_class 98 | ); 99 | 100 | $class_name = $injection_chain->get_class(); 101 | 102 | if ( $this->has_shared_instance( $class_name ) ) { 103 | return $this->get_shared_instance( $class_name ); 104 | } 105 | 106 | if ( $this->has_delegate( $class_name ) ) { 107 | $delegate = $this->get_delegate( $class_name ); 108 | $object = $delegate( $class_name ); 109 | 110 | if ( ! is_object( $object ) ) { 111 | throw FailedToMakeInstance::for_invalid_delegate( $class_name ); 112 | } 113 | 114 | return $object; 115 | } 116 | 117 | $reflection = $this->get_class_reflection( $class_name ); 118 | $this->ensure_is_instantiable( $reflection ); 119 | $dependencies = $this->get_dependencies_for( 120 | $injection_chain, 121 | $reflection, 122 | $arguments 123 | ); 124 | $object = $this->instantiator->instantiate( $class_name, $dependencies ); 125 | 126 | if ( \array_key_exists( $class_name, $this->shared_instances ) ) { 127 | $this->shared_instances[ $class_name ] = $object; 128 | } 129 | 130 | return $object; 131 | } 132 | 133 | /** 134 | * Bind a given interface or class to an implementation. 135 | * 136 | * Note: The implementation can be an interface as well, as long as it can 137 | * be resolved to an instantiatable class at runtime. 138 | * 139 | * @param class-string $from Interface or class to bind an implementation to. 140 | * @param class-string $to Interface or class that provides the implementation. 141 | * 142 | * @return self 143 | */ 144 | public function bind( string $from, string $to ): Injector { 145 | $this->mappings[ $from ] = $to; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Bind an argument for a class to a specific value. 152 | * 153 | * @param class-string $interface_or_class Interface or class to bind an argument 154 | * for. 155 | * @param string $argument_name Argument name to bind a value to. 156 | * @param mixed $value Value to bind the argument to. 157 | * 158 | * @return self 159 | */ 160 | public function bind_argument( 161 | string $interface_or_class, 162 | string $argument_name, 163 | $value 164 | ): Injector { 165 | $this->argument_mappings[ $interface_or_class ][ $argument_name ] = $value; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Always reuse and share the same instance for the provided interface or 172 | * class. 173 | * 174 | * @param class-string $interface_or_class Interface or class to reuse. 175 | * 176 | * @return self 177 | */ 178 | public function share( string $interface_or_class ): Injector { 179 | $this->shared_instances[ $interface_or_class ] = null; 180 | 181 | return $this; 182 | } 183 | 184 | /** 185 | * Delegate instantiation of an interface or class to a callable. 186 | * 187 | * @param class-string $interface_or_class Interface or class to delegate the 188 | * instantiation of. 189 | * @param callable $delegation Callable to use for instantiation. 190 | * 191 | * @return self 192 | */ 193 | public function delegate( string $interface_or_class, callable $delegation ): Injector { 194 | $this->delegates[ $interface_or_class ] = $delegation; 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * Make an object instance out of an interface or class. 201 | * 202 | * @param InjectionChain $injection_chain Injection chain to track 203 | * resolutions. 204 | * @param class-string $interface_or_class Interface or class to make an 205 | * object instance out of. 206 | * @return object Instantiated object. 207 | * @throws FailedToMakeInstance If the object could not be instantiated. 208 | */ 209 | private function make_dependency( 210 | InjectionChain $injection_chain, 211 | string $interface_or_class 212 | ): object { 213 | $injection_chain = $this->resolve( 214 | $injection_chain, 215 | $interface_or_class 216 | ); 217 | 218 | $class_name = $injection_chain->get_class(); 219 | 220 | if ( $this->has_shared_instance( $class_name ) ) { 221 | return $this->get_shared_instance( $class_name ); 222 | } 223 | 224 | if ( $this->has_delegate( $class_name ) ) { 225 | $delegate = $this->get_delegate( $class_name ); 226 | $object = $delegate( $class_name ); 227 | 228 | if ( ! is_object( $object ) ) { 229 | throw FailedToMakeInstance::for_invalid_delegate( $class_name ); 230 | } 231 | 232 | return $object; 233 | } 234 | 235 | $reflection = $this->get_class_reflection( $class_name ); 236 | $this->ensure_is_instantiable( $reflection ); 237 | 238 | $dependencies = $this->get_dependencies_for( 239 | $injection_chain, 240 | $reflection 241 | ); 242 | 243 | $object = $this->instantiator->instantiate( $class_name, $dependencies ); 244 | 245 | if ( \array_key_exists( $class_name, $this->shared_instances ) ) { 246 | $this->shared_instances[ $class_name ] = $object; 247 | } 248 | 249 | return $object; 250 | } 251 | 252 | /** 253 | * Recursively resolve an interface to the class it should be bound to. 254 | * 255 | * @param InjectionChain $injection_chain Injection chain to track 256 | * resolutions. 257 | * @param class-string $interface_or_class Interface or class to resolve. 258 | * @return InjectionChain Modified Injection chain. 259 | * @throws FailedToMakeInstance If a circular reference is detected. 260 | */ 261 | private function resolve( 262 | InjectionChain $injection_chain, 263 | string $interface_or_class 264 | ): InjectionChain { 265 | if ( $injection_chain->has_resolution( $interface_or_class ) ) { 266 | // Circular reference detected, aborting. 267 | throw FailedToMakeInstance::for_circular_reference( 268 | $interface_or_class 269 | ); 270 | } 271 | 272 | $injection_chain = $injection_chain->add_resolution( $interface_or_class ); 273 | 274 | if ( \array_key_exists( $interface_or_class, $this->mappings ) ) { 275 | return $this->resolve( 276 | $injection_chain, 277 | $this->mappings[ $interface_or_class ] 278 | ); 279 | } 280 | 281 | return $injection_chain->add_to_chain( $interface_or_class ); 282 | } 283 | 284 | /** 285 | * Get the array of constructor dependencies for a given reflected class. 286 | * 287 | * @param InjectionChain $injection_chain Injection chain to track 288 | * resolutions. 289 | * @param ReflectionClass $reflection Reflected class to get the 290 | * dependencies for. 291 | * @param array $arguments Associative array of directly 292 | * provided arguments. 293 | * @return array Array of dependencies that represent the arguments for the class' constructor. 294 | */ 295 | private function get_dependencies_for( 296 | InjectionChain $injection_chain, 297 | ReflectionClass $reflection, 298 | array $arguments = [] 299 | ): array { 300 | $constructor = $reflection->getConstructor(); 301 | $class = $reflection->getName(); 302 | 303 | if ( null === $constructor ) { 304 | return []; 305 | } 306 | 307 | /** 308 | * The keys will be preserved in the returned array. 309 | * 310 | * @var array 311 | */ 312 | return \array_map( 313 | /** 314 | * Mixed return can only be provided directly from PHP 8.0 onwards. 315 | * 316 | * @return mixed 317 | */ 318 | fn( ReflectionParameter $parameter ) => $this->resolve_argument( 319 | $injection_chain, 320 | $class, 321 | $parameter, 322 | $arguments 323 | ), 324 | $constructor->getParameters() 325 | ); 326 | } 327 | 328 | /** 329 | * Ensure that a given reflected class is instantiable. 330 | * 331 | * @param ReflectionClass $reflection Reflected class to check. 332 | * @throws FailedToMakeInstance If the interface could not be resolved. 333 | */ 334 | private function ensure_is_instantiable( ReflectionClass $reflection ): void { 335 | if ( ! $reflection->isInstantiable() ) { 336 | throw FailedToMakeInstance::for_unresolved_interface( $reflection->getName() ); 337 | } 338 | } 339 | 340 | /** 341 | * Resolve a given reflected argument. 342 | * 343 | * @param InjectionChain $injection_chain Injection chain to track 344 | * resolutions. 345 | * @param class-string $class_name Name of the class to 346 | * resolve the arguments for. 347 | * @param ReflectionParameter $parameter Parameter to resolve. 348 | * @param array $arguments Associative array of 349 | * directly provided 350 | * arguments. 351 | * @return mixed Resolved value of the argument. 352 | */ 353 | private function resolve_argument( 354 | InjectionChain $injection_chain, 355 | string $class_name, 356 | ReflectionParameter $parameter, 357 | array $arguments 358 | ) { 359 | if ( ! $parameter->hasType() ) { 360 | return $this->resolve_argument_by_name( 361 | $class_name, 362 | $parameter, 363 | $arguments 364 | ); 365 | } 366 | 367 | /** 368 | * Type can vary based on PHP version. 369 | * 370 | * @var ReflectionType|ReflectionNamedType|null $type 371 | */ 372 | $type = $parameter->getType(); 373 | 374 | /* 375 | * @psalm-suppress UndefinedMethod,TypeDoesNotContainNull 376 | * @phpstan-ignore method.notFound (Method was moved to ReflectionNamedType in PHP 8.0) 377 | */ 378 | if ( null === $type || $type->isBuiltin() ) { 379 | return $this->resolve_argument_by_name( 380 | $class_name, 381 | $parameter, 382 | $arguments 383 | ); 384 | } 385 | 386 | /** 387 | * We need to deal with differences between PHP versions here. 388 | * 389 | * @var class-string $type 390 | * 391 | * @disregard P1009 as this is a different type in PHP 8. 392 | */ 393 | $type = $type instanceof ReflectionNamedType 394 | ? $type->getName() 395 | : (string) $type; 396 | 397 | return $this->make_dependency( $injection_chain, $type ); 398 | } 399 | 400 | /** 401 | * Resolve a given reflected argument by its name. 402 | * 403 | * @param class-string $class_name Class to resolve the argument for. 404 | * @param ReflectionParameter $parameter Argument to resolve by name. 405 | * @param array $arguments Associative array of directly 406 | * provided arguments. 407 | * @return mixed Resolved value of the argument. 408 | * @throws FailedToMakeInstance If the argument could not be resolved. 409 | */ 410 | private function resolve_argument_by_name( 411 | string $class_name, 412 | ReflectionParameter $parameter, 413 | array $arguments 414 | ) { 415 | $name = $parameter->getName(); 416 | 417 | // The argument was directly provided to the make() call. 418 | if ( \array_key_exists( $name, $arguments ) ) { 419 | return $arguments[ $name ]; 420 | } 421 | 422 | // Check if we have mapped this argument for the specific class. 423 | if ( \array_key_exists( $class_name, $this->argument_mappings ) 424 | && \array_key_exists( $name, $this->argument_mappings[ $class_name ] ) ) { 425 | return $this->argument_mappings[ $class_name ][ $name ]; 426 | } 427 | 428 | // No argument found for the class, check if we have a global value. 429 | if ( \array_key_exists( $name, $this->argument_mappings[ self::GLOBAL_ARGUMENTS ] ) ) { 430 | return $this->argument_mappings[ self::GLOBAL_ARGUMENTS ][ $name ]; 431 | } 432 | 433 | // No provided argument found, check if it has a default value. 434 | try { 435 | if ( $parameter->isDefaultValueAvailable() ) { 436 | return $parameter->getDefaultValue(); 437 | } 438 | } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement 439 | // Just fall through into the FailedToMakeInstance exception. 440 | } 441 | 442 | // Out of options, fail with an exception. 443 | throw FailedToMakeInstance::for_unresolved_argument( $name, $class_name ); 444 | } 445 | 446 | /** 447 | * Check whether a shared instance exists for a given class. 448 | * 449 | * @param class-string $class_name Class to check for a shared instance. 450 | * @return bool Whether a shared instance exists. 451 | */ 452 | private function has_shared_instance( string $class_name ): bool { 453 | return \array_key_exists( $class_name, $this->shared_instances ) 454 | && null !== $this->shared_instances[ $class_name ]; 455 | } 456 | 457 | /** 458 | * Get the shared instance for a given class. 459 | * 460 | * @param class-string $class_name Class to get the shared instance for. 461 | * @return object Shared instance. 462 | * @throws FailedToMakeInstance If the shared instance could not be found. 463 | */ 464 | private function get_shared_instance( string $class_name ): object { 465 | if ( ! $this->has_shared_instance( $class_name ) ) { 466 | throw FailedToMakeInstance::for_uninstantiated_shared_instance( $class_name ); 467 | } 468 | 469 | return (object) $this->shared_instances[ $class_name ]; 470 | } 471 | 472 | /** 473 | * Check whether a delegate exists for a given class. 474 | * 475 | * @param class-string $class_name Class to check for a delegate. 476 | * @return bool Whether a delegate exists. 477 | */ 478 | private function has_delegate( string $class_name ): bool { 479 | return \array_key_exists( $class_name, $this->delegates ); 480 | } 481 | 482 | /** 483 | * Get the delegate for a given class. 484 | * 485 | * @param class-string $class_name Class to get the delegate for. 486 | * @return callable Delegate. 487 | * @throws FailedToMakeInstance If the delegate could not be found. 488 | */ 489 | private function get_delegate( string $class_name ): callable { 490 | if ( ! $this->has_delegate( $class_name ) ) { 491 | throw FailedToMakeInstance::for_invalid_delegate( $class_name ); 492 | } 493 | 494 | return $this->delegates[ $class_name ]; 495 | } 496 | 497 | /** 498 | * Get the reflection for a class or throw an exception. 499 | * 500 | * @param class-string $class_name Class to get the reflection for. 501 | * @return ReflectionClass Class reflection. 502 | * @throws FailedToMakeInstance If the class could not be reflected. 503 | * @phpstan-param class-string $class_name 504 | */ 505 | private function get_class_reflection( string $class_name ): ReflectionClass { 506 | try { 507 | $reflection = new ReflectionClass( $class_name ); 508 | 509 | if ( $reflection->getName() !== $class_name ) { 510 | throw FailedToMakeInstance::for_invalid_reflection( $class_name ); 511 | } 512 | 513 | return $reflection; 514 | } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement 515 | // Just fall through into the FailedToMakeInstance exception. 516 | } 517 | 518 | throw FailedToMakeInstance::for_unreflectable_class( $class_name ); 519 | } 520 | 521 | /** 522 | * Get a fallback instantiator in case none was provided. 523 | * 524 | * @return Instantiator Simplistic fallback instantiator. 525 | */ 526 | private function get_fallback_instantiator(): Instantiator { 527 | return new class() implements Instantiator { 528 | 529 | /** 530 | * Make an object instance out of an interface or class. 531 | * 532 | * @param class-string $class_name Class to make an object instance out of. 533 | * @param array $dependencies Optional. Dependencies of the class. 534 | * @return object Instantiated object. 535 | */ 536 | public function instantiate( string $class_name, array $dependencies = [] ): object { 537 | return new $class_name( ...$dependencies ); 538 | } 539 | }; 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /src/Infrastructure/Instantiator.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Interface to make the act of instantiation extensible/replaceable. 18 | * 19 | * This way, a more elaborate mechanism can be plugged in, like using 20 | * ProxyManager to instantiate proxies instead of actual objects. 21 | */ 22 | interface Instantiator { 23 | 24 | /** 25 | * Make an object instance out of an interface or class. 26 | * 27 | * @param class-string $class_name Class to make an object instance out of. 28 | * @param array $dependencies Optional. Dependencies of the class. 29 | * @return object Instantiated object. 30 | */ 31 | public function instantiate( string $class_name, array $dependencies = [] ): object; 32 | } 33 | -------------------------------------------------------------------------------- /src/Infrastructure/Plugin.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * A plugin is basically nothing more than a convention on how manage the 18 | * lifecycle of a modular piece of code, so that you can: 19 | * 1. activate it, 20 | * 2. register it with the framework, and 21 | * 3. deactivate it again. 22 | * 23 | * This is what this interface represents, by assembling the separate, 24 | * segregated interfaces for each of these lifecycle actions. 25 | * 26 | * Additionally, we provide a means to get access to the plugin's container that 27 | * collects all the services it is made up of. This allows direct access to the 28 | * services to outside code if needed. 29 | */ 30 | interface Plugin extends Activateable, Deactivateable, Registerable { 31 | 32 | /** 33 | * Get the service container that contains the services that make up the 34 | * plugin. 35 | * 36 | * @return ServiceContainer Service container of the plugin. 37 | */ 38 | public function get_container(): ServiceContainer; 39 | } 40 | -------------------------------------------------------------------------------- /src/Infrastructure/Registerable.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that can be registered. 18 | * 19 | * For a clean code base, a class instantiation should never have side-effects, 20 | * only initialize the internals of the object so that it is ready to be used. 21 | * 22 | * This means, though, that the system does not have any knowledge of the 23 | * objects when they are merely instantiated. 24 | * 25 | * Registering such an object is the explicit act of making it known to the 26 | * overarching system. 27 | */ 28 | interface Registerable { 29 | 30 | /** 31 | * Register the service. 32 | */ 33 | public function register(): void; 34 | } 35 | -------------------------------------------------------------------------------- /src/Infrastructure/Renderable.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * Something that can be rendered. 18 | * 19 | * This can be used for views, obviously, but could just as well be used for 20 | * other concepts like blocks or shortcodes, value objects, ... 21 | */ 22 | interface Renderable { 23 | 24 | /** 25 | * Render the renderable. 26 | * 27 | * @param array $context Optional. Contextual information to use while 28 | * rendering. Defaults to an empty array. 29 | * @return string Rendered result. 30 | */ 31 | public function render( array $context = [] ): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/Infrastructure/Service.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * A conceptual service. 18 | * 19 | * Splitting your logic up into independent services makes the approach of 20 | * assembling a plugin more systematic and scalable and lowers the cognitive 21 | * load when the code base increases in size. 22 | */ 23 | interface Service { 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Infrastructure/Service/DebugMode.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\Service; 15 | 16 | /** 17 | * Interface for a debug mode service. 18 | * 19 | * This allows for testing the debug mode service without having to rely on 20 | * the global state of the WordPress constant. 21 | */ 22 | interface DebugMode { 23 | 24 | /** 25 | * Check if the application is in debug mode. 26 | * 27 | * @return bool True if debug mode is active, false otherwise. 28 | */ 29 | public function is_debug_mode(): bool; 30 | } 31 | -------------------------------------------------------------------------------- /src/Infrastructure/Service/WordPressDebugMode.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\Service; 15 | 16 | /** 17 | * A debug mode service that uses the global state of the WordPress constant. 18 | */ 19 | final class WordPressDebugMode implements DebugMode { 20 | 21 | /** 22 | * Check if the application is in debug mode. 23 | * 24 | * @return bool True if debug mode is active, false otherwise. 25 | */ 26 | public function is_debug_mode(): bool { 27 | return defined( 'WP_DEBUG' ) && WP_DEBUG; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceBasedPlugin.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\Injector\SimpleInjector; 17 | use MWPD\BasicScaffold\Infrastructure\ServiceContainer\SimpleServiceContainer; 18 | use MWPD\BasicScaffold\Exception\InvalidArgument; 19 | use MWPD\BasicScaffold\Exception\InvalidConfiguration; 20 | use MWPD\BasicScaffold\Exception\InvalidService; 21 | use MWPD\BasicScaffold\Infrastructure\ServiceContainer\LazilyInstantiatedService; 22 | 23 | /** 24 | * This abstract base plugin provides all the boilerplate code for working with 25 | * the dependency injector and the service container. 26 | */ 27 | abstract class ServiceBasedPlugin implements Plugin { 28 | 29 | // Main filters to control the flow of the plugin from outside code. 30 | 31 | /** 32 | * Filter to control the services that are registered by the plugin. 33 | * 34 | * @var non-empty-string 35 | */ 36 | public const SERVICES_FILTER = 'services'; 37 | 38 | /** 39 | * Filter to control the bindings of the dependency injector. 40 | * 41 | * @var non-empty-string 42 | */ 43 | public const BINDINGS_FILTER = 'bindings'; 44 | 45 | /** 46 | * Filter to control the argument bindings of the dependency injector. 47 | * 48 | * @var non-empty-string 49 | */ 50 | public const ARGUMENTS_FILTER = 'arguments'; 51 | 52 | /** 53 | * Filter to control the shared instances of the dependency injector. 54 | * 55 | * @var non-empty-string 56 | */ 57 | public const SHARED_INSTANCES_FILTER = 'shared_instances'; 58 | 59 | /** 60 | * Filter to control the delegations of the dependency injector. 61 | * 62 | * @var non-empty-string 63 | */ 64 | public const DELEGATIONS_FILTER = 'delegations'; 65 | 66 | /** 67 | * Identifier for the injector service. 68 | * 69 | * @var non-empty-string 70 | */ 71 | public const INJECTOR_ID = 'injector'; 72 | 73 | /** 74 | * WordPress action to trigger the service registration on. 75 | * 76 | * @var non-empty-string 77 | */ 78 | protected const REGISTRATION_ACTION = 'plugins_loaded'; 79 | 80 | /** 81 | * Hook prefix to use. 82 | * 83 | * This is used to prefix all the hooks that are used by the plugin to avoid conflicts. 84 | * 85 | * @var string 86 | */ 87 | protected const HOOK_PREFIX = ''; 88 | 89 | /** 90 | * Service prefix to use. 91 | * 92 | * This is used to prefix all the services that are registered by the plugin. 93 | * 94 | * @var string 95 | */ 96 | protected const SERVICE_PREFIX = ''; 97 | 98 | /** 99 | * Whether to enable filtering of the injector configuration. 100 | */ 101 | protected bool $enable_filters; 102 | 103 | /** 104 | * Injector instance. 105 | */ 106 | protected Injector $injector; 107 | 108 | /** 109 | * Service container instance. 110 | */ 111 | protected ServiceContainer $service_container; 112 | 113 | /** 114 | * Instantiate a Plugin object. 115 | * 116 | * @param bool $enable_filters Optional. Whether to 117 | * enable filtering of the 118 | * injector configuration. 119 | * @param Injector|null $injector Optional. Injector 120 | * instance to use. 121 | * @param ServiceContainer|null $service_container Optional. Service 122 | * container instance to 123 | * use. 124 | */ 125 | public function __construct( 126 | bool $enable_filters = true, 127 | ?Injector $injector = null, 128 | ?ServiceContainer $service_container = null 129 | ) { 130 | /* 131 | * We use what is commonly referred to as a "poka-yoke" here. 132 | * 133 | * We need an injector and a container. We make them injectable so that 134 | * we can easily provide overrides for testing, but we also make them 135 | * optional and provide default implementations for easy regular usage. 136 | */ 137 | 138 | $this->enable_filters = $enable_filters; 139 | $this->injector = $injector ?? new SimpleInjector(); 140 | $this->injector = $this->configure_injector( $this->injector ); 141 | 142 | $this->service_container = $service_container ?? new SimpleServiceContainer(); 143 | } 144 | 145 | /** 146 | * Activate the plugin. 147 | */ 148 | public function activate(): void { 149 | $this->register_services(); 150 | 151 | foreach ( $this->service_container as $service ) { 152 | if ( $service instanceof Activateable ) { 153 | $service->activate(); 154 | } 155 | } 156 | 157 | \flush_rewrite_rules(); 158 | } 159 | 160 | /** 161 | * Deactivate the plugin. 162 | */ 163 | public function deactivate(): void { 164 | $this->register_services(); 165 | 166 | foreach ( $this->service_container as $service ) { 167 | if ( $service instanceof Deactivateable ) { 168 | $service->deactivate(); 169 | } 170 | } 171 | 172 | \flush_rewrite_rules(); 173 | } 174 | 175 | /** 176 | * Register the plugin with the WordPress system. 177 | * 178 | * @throws InvalidService If a service is not valid. 179 | */ 180 | public function register(): void { 181 | \add_action( 182 | static::REGISTRATION_ACTION, 183 | [ $this, 'register_services' ], 184 | 10, 185 | 0 186 | ); 187 | } 188 | 189 | /** 190 | * Register the individual services of this plugin. 191 | * 192 | * @throws InvalidService If a service is not valid. 193 | */ 194 | public function register_services(): void { 195 | // Bail early so we don't instantiate services twice. 196 | if ( count( $this->service_container ) > 0 ) { 197 | return; 198 | } 199 | 200 | // Add the injector as the very first service. 201 | $this->service_container->put( 202 | static::SERVICE_PREFIX . static::INJECTOR_ID, 203 | $this->injector 204 | ); 205 | 206 | $services = $this->get_service_classes(); 207 | 208 | if ( $this->enable_filters ) { 209 | /** 210 | * Filter the default services that make up this plugin. 211 | * 212 | * This can be used to add services to the service container for 213 | * this plugin. 214 | * 215 | * @param array $services Associative array of 216 | * identifiers mapped to 217 | * fully qualified class 218 | * names or callables. 219 | * @psalm-suppress InvalidArgument 220 | */ 221 | $services = \apply_filters( 222 | static::HOOK_PREFIX . static::SERVICES_FILTER, 223 | $services 224 | ); 225 | } 226 | 227 | while ( null !== key( $services ) ) { 228 | $id = key( $services ); 229 | $class_name = $this->maybe_resolve( current( $services ) ); 230 | 231 | if ( ! is_string( $id ) ) { 232 | throw InvalidService::from_invalid_identifier( $id ); 233 | } 234 | 235 | if ( ! is_string( $class_name ) ) { 236 | throw InvalidService::from_invalid_class_name( $class_name ); 237 | } 238 | 239 | /** 240 | * The resolved value is guaranteed to be a class name at this point. 241 | * 242 | * @var class-string $class_name 243 | */ 244 | 245 | if ( $class_name !== current( $services ) ) { 246 | $services[ $id ] = $class_name; 247 | } 248 | 249 | /** 250 | * The resolved value is guaranteed to be a class name at this point. 251 | * 252 | * @var class-string $class_name 253 | */ 254 | 255 | // Delay registering the service until all dependencies are met. 256 | if ( is_a( $class_name, HasDependencies::class, true ) && 257 | ! $this->dependencies_are_met( $id, $class_name, $services ) ) { 258 | next( $services ); 259 | continue; 260 | } 261 | 262 | $this->schedule_potential_service_registration( $id, $class_name ); 263 | next( $services ); 264 | } 265 | } 266 | 267 | /** 268 | * The service registration works in three steps: 269 | * 270 | * 1. All services that need to be registered are gathered. 271 | * 2. A first pass over the services registers all those that either don't have 272 | * dependencies or where all dependencies are met already. 273 | * 3. A second pass registers the remaining services as soon as their 274 | * dependencies are met. 275 | * 276 | * The first pass is done directly from the register_services() method, as it 277 | * needs to ensure that the services are registered in the order they were 278 | * provided. 279 | * 280 | * The second pass is done through schedule_potential_service_registration(), 281 | * which adds the service to the registration schedule. For regular services, 282 | * this means they are registered immediately. For delayed services, this means 283 | * they are only registered upon their registration action. 284 | * 285 | * Services that have delayed dependencies are registered as soon as all their 286 | * dependencies are available. This is done by registering a callback to each 287 | * dependency's registration action hook with a high priority. This means that 288 | * the service's registration is triggered by the first dependency that was 289 | * registered. It then checks if all other dependencies are available as well, 290 | * and if so, registers the service. 291 | * 292 | * @param string $id ID of the service to register. 293 | * @param class-string $class_name Class of the service to register. 294 | */ 295 | protected function schedule_potential_service_registration( string $id, string $class_name ): void { 296 | if ( is_a( $class_name, Delayed::class, true ) ) { 297 | $registration_action = $class_name::get_registration_action(); 298 | 299 | if ( \did_action( $registration_action ) ) { 300 | $this->maybe_register_service( $id, $class_name ); 301 | } else { 302 | \add_action( 303 | $registration_action, 304 | function () use ( $id, $class_name ): void { 305 | $this->maybe_register_service( $id, $class_name ); 306 | }, 307 | 10, 308 | 0 309 | ); 310 | } 311 | } else { 312 | $this->maybe_register_service( $id, $class_name ); 313 | } 314 | } 315 | 316 | /** 317 | * The maybe_register_service() method is the third step of registering a service. 318 | * It checks whether the service was registered before and whether it is actually 319 | * needed, and only then registers it. 320 | * 321 | * The three checks being done are: 322 | * 1. Is the service already registered? => Skip if yes. 323 | * 2. Is the service conditional? => Skip if conditions not met. 324 | * 3. Register the service. 325 | * 326 | * @param string $id ID of the service to register. 327 | * @param class-string $class_name Class of the service to register. 328 | */ 329 | protected function maybe_register_service( string $id, string $class_name ): void { 330 | // Ensure we don't register the same service more than once. 331 | if ( $this->service_container->has( $id ) ) { 332 | return; 333 | } 334 | 335 | // Only instantiate services that are actually needed. 336 | if ( is_a( $class_name, Conditional::class, true ) && ! $class_name::is_needed() ) { 337 | return; 338 | } 339 | 340 | $service = $this->instantiate_service( $class_name ); 341 | $this->service_container->put( $id, $service ); 342 | 343 | if ( $service instanceof Registerable ) { 344 | $service->register(); 345 | } 346 | } 347 | 348 | /** 349 | * Get the service container that contains the services that make up the 350 | * plugin. 351 | * 352 | * @return ServiceContainer Service container of the plugin. 353 | */ 354 | public function get_container(): ServiceContainer { 355 | return $this->service_container; 356 | } 357 | 358 | /** 359 | * Instantiate a single service. 360 | * 361 | * @param class-string $class_name Service class to instantiate. 362 | * 363 | * @throws InvalidService If the service could not be properly instantiated. 364 | * 365 | * @return Service Instantiated service. 366 | */ 367 | protected function instantiate_service( $class_name ): Service { 368 | /* 369 | * If the service is not registerable, we default to lazily instantiated 370 | * services here for some basic optimization. 371 | * 372 | * The services will be properly instantiated once they are retrieved 373 | * from the service container. 374 | */ 375 | if ( ! is_a( $class_name, Registerable::class, true ) ) { 376 | return new LazilyInstantiatedService( 377 | fn(): object => $this->injector->make( $class_name ) 378 | ); 379 | } 380 | 381 | // The service needs to be registered, so instantiate right away. 382 | $service = $this->injector->make( $class_name ); 383 | 384 | if ( ! $service instanceof Service ) { 385 | throw InvalidService::from_service( $service ); 386 | } 387 | 388 | return $service; 389 | } 390 | 391 | /** 392 | * Configure the provided injector. 393 | * 394 | * This method defines the mappings that the injector knows about, and the 395 | * logic it requires to make more complex instantiations work. 396 | * 397 | * For more complex plugins, this should be extracted into a separate 398 | * object 399 | * or into configuration files. 400 | * 401 | * @param Injector $injector Injector instance to configure. 402 | * @return Injector Configured injector instance. 403 | * @throws InvalidArgument If an argument is not recognized. 404 | * @throws InvalidConfiguration If the injector configuration structure is invalid. 405 | * @throws InvalidService If the injector configuration details are invalid. 406 | */ 407 | protected function configure_injector( Injector $injector ): Injector { 408 | $bindings = $this->get_bindings(); 409 | $shared_instances = $this->get_shared_instances(); 410 | $arguments = $this->get_arguments(); 411 | $delegations = $this->get_delegations(); 412 | 413 | if ( $this->enable_filters ) { 414 | /** 415 | * Filter the default bindings that are provided by the plugin. 416 | * 417 | * This can be used to swap implementations out for alternatives. 418 | * 419 | * @param array $bindings Associative array of 420 | * interface => 421 | * implementation 422 | * bindings. Both 423 | * should be FQCNs. 424 | * @return array Modified bindings. 425 | * @psalm-suppress InvalidArgument 426 | */ 427 | $bindings = \apply_filters( 428 | static::HOOK_PREFIX . static::BINDINGS_FILTER, 429 | $bindings 430 | ); 431 | 432 | /** 433 | * Filter the default argument bindings that are provided by the 434 | * plugin. 435 | * 436 | * This can be used to override scalar values. 437 | * 438 | * @param array> $arguments Associative array of 439 | * class => arguments 440 | * mappings. The arguments 441 | * array maps argument names 442 | * to values. 443 | * @return array> Modified arguments. 444 | */ 445 | $arguments = \apply_filters( 446 | static::HOOK_PREFIX . static::ARGUMENTS_FILTER, 447 | $arguments 448 | ); 449 | 450 | /** 451 | * Filter the instances that are shared by default by the plugin. 452 | * 453 | * This can be used to turn objects that were added externally into 454 | * shared instances. 455 | * 456 | * @param array $shared_instances Array of FQCNs to turn 457 | * into shared objects. 458 | * @return array Modified shared instances. 459 | * @psalm-suppress InvalidArgument 460 | */ 461 | $shared_instances = \apply_filters( 462 | static::HOOK_PREFIX . static::SHARED_INSTANCES_FILTER, 463 | $shared_instances 464 | ); 465 | 466 | /** 467 | * Filter the delegations that are provided by the plugin. 468 | * 469 | * This can be used to override the default delegation logic for a 470 | * class. 471 | * 472 | * @param array $delegations Associative array of class => 473 | * callable mappings. 474 | * @return array Modified delegations. 475 | */ 476 | $delegations = \apply_filters( 477 | static::HOOK_PREFIX . static::DELEGATIONS_FILTER, 478 | $delegations 479 | ); 480 | } 481 | 482 | $injector = $this->parse_bindings( $bindings, $injector ); 483 | $injector = $this->parse_arguments( $arguments, $injector ); 484 | $injector = $this->parse_shared_instances( $shared_instances, $injector ); 485 | $injector = $this->parse_delegations( $delegations, $injector ); 486 | 487 | return $injector; 488 | } 489 | 490 | /** 491 | * Parse the bindings configuration. 492 | * 493 | * @param mixed $bindings Associative array of fully qualified class names. 494 | * @param Injector $injector Injector instance to configure. 495 | * @return Injector Configured injector instance. 496 | * @throws InvalidConfiguration If the bindings configuration is invalid. 497 | * @throws InvalidService If the bindings configuration details are invalid. 498 | */ 499 | protected function parse_bindings( $bindings, Injector $injector ): Injector { 500 | if ( ! is_array( $bindings ) ) { 501 | throw InvalidConfiguration::from_invalid_bindings( $bindings ); 502 | } 503 | 504 | foreach ( $bindings as $from => $to ) { 505 | $to = $this->maybe_resolve( $to ); 506 | 507 | if ( ! is_string( $from ) ) { 508 | throw InvalidService::from_invalid_identifier( $from ); 509 | } 510 | 511 | if ( ! is_string( $to ) ) { 512 | throw InvalidService::from_invalid_identifier( $to ); 513 | } 514 | 515 | /** 516 | * The resolved values are guaranteed to be strings at this point. 517 | * 518 | * @var class-string $from 519 | * @var class-string $to 520 | */ 521 | 522 | $injector = $injector->bind( $from, $to ); 523 | } 524 | 525 | return $injector; 526 | } 527 | 528 | /** 529 | * Parse the arguments configuration. 530 | * 531 | * @param mixed $arguments Associative array of class names and argument maps. 532 | * @param Injector $injector Injector instance to configure. 533 | * @return Injector Configured injector instance. 534 | * @throws InvalidArgument If the argument name is not a string. 535 | * @throws InvalidConfiguration If the arguments configuration is invalid. 536 | * @throws InvalidService If the arguments configuration details are invalid. 537 | */ 538 | protected function parse_arguments( $arguments, Injector $injector ): Injector { 539 | if ( ! is_array( $arguments ) ) { 540 | throw InvalidConfiguration::from_invalid_arguments( $arguments ); 541 | } 542 | 543 | foreach ( $arguments as $class_name => $argument_map ) { 544 | $class_name = $this->maybe_resolve( $class_name ); 545 | 546 | if ( ! is_string( $class_name ) ) { 547 | throw InvalidService::from_invalid_identifier( $class_name ); 548 | } 549 | 550 | /** 551 | * The resolved value is guaranteed to be a string at this point. 552 | * 553 | * @var class-string $class_name 554 | */ 555 | 556 | if ( ! is_array( $argument_map ) ) { 557 | throw InvalidService::from_invalid_argument_map( $class_name, $argument_map ); 558 | } 559 | 560 | foreach ( $argument_map as $name => $value ) { 561 | // We don't try to resolve the $value here, as we might want to 562 | // pass a callable as-is. 563 | $name = $this->maybe_resolve( $name ); 564 | 565 | if ( ! is_string( $name ) ) { 566 | throw InvalidArgument::from_name( $name ); 567 | } 568 | 569 | $injector = $injector->bind_argument( $class_name, $name, $value ); 570 | } 571 | } 572 | 573 | return $injector; 574 | } 575 | 576 | /** 577 | * Parse the shared instances configuration. 578 | * 579 | * @param mixed $shared_instances Array of class names. 580 | * @param Injector $injector Injector instance to configure. 581 | * @return Injector Configured injector instance. 582 | * @throws InvalidConfiguration If the shared instances configuration is invalid. 583 | * @throws InvalidService If the shared instances configuration details are invalid. 584 | */ 585 | protected function parse_shared_instances( $shared_instances, Injector $injector ): Injector { 586 | if ( ! is_array( $shared_instances ) ) { 587 | throw InvalidConfiguration::from_invalid_shared_instances( $shared_instances ); 588 | } 589 | 590 | foreach ( $shared_instances as $shared_instance ) { 591 | $shared_instance = $this->maybe_resolve( $shared_instance ); 592 | 593 | if ( ! is_string( $shared_instance ) ) { 594 | throw InvalidService::from_invalid_identifier( $shared_instance ); 595 | } 596 | 597 | /** 598 | * The resolved value is guaranteed to be a string at this point. 599 | * 600 | * @var class-string $shared_instance 601 | */ 602 | 603 | $injector = $injector->share( $shared_instance ); 604 | } 605 | 606 | return $injector; 607 | } 608 | 609 | /** 610 | * Parse the delegations configuration. 611 | * 612 | * @param mixed $delegations Associative array of class names and callables. 613 | * @param Injector $injector Injector instance to configure. 614 | * @return Injector Configured injector instance. 615 | * @throws InvalidConfiguration If the delegations configuration is invalid. 616 | * @throws InvalidService If the delegations configuration details are invalid. 617 | */ 618 | protected function parse_delegations( $delegations, Injector $injector ): Injector { 619 | if ( ! is_array( $delegations ) ) { 620 | throw InvalidConfiguration::from_invalid_delegations( $delegations ); 621 | } 622 | 623 | foreach ( $delegations as $class_name => $delegation ) { 624 | // We don't try to resolve the $callable here, as we want to pass it 625 | // on as-is. 626 | $class_name = $this->maybe_resolve( $class_name ); 627 | 628 | if ( ! is_string( $class_name ) ) { 629 | throw InvalidService::from_invalid_identifier( $class_name ); 630 | } 631 | 632 | /** 633 | * The resolved value is guaranteed to be a string at this point. 634 | * 635 | * @var class-string $class_name 636 | */ 637 | 638 | if ( ! is_callable( $delegation ) ) { 639 | throw InvalidService::from_invalid_delegation( $class_name, $delegation ); 640 | } 641 | 642 | $injector = $injector->delegate( $class_name, $delegation ); 643 | } 644 | 645 | return $injector; 646 | } 647 | 648 | /** 649 | * Get the list of services to register. 650 | * 651 | * @return array Associative array of identifiers 652 | * mapped to fully qualified class 653 | * names or callables. 654 | */ 655 | protected function get_service_classes(): array { 656 | return []; 657 | } 658 | 659 | /** 660 | * Get the bindings for the dependency injector. 661 | * 662 | * The bindings let you map interfaces (or classes) to the classes that 663 | * should be used to implement them. 664 | * 665 | * @return array Associative array of fully qualified class names. 666 | */ 667 | protected function get_bindings(): array { 668 | return []; 669 | } 670 | 671 | /** 672 | * Get the argument bindings for the dependency injector. 673 | * 674 | * The argument bindings let you map specific argument values for specific 675 | * classes. 676 | * 677 | * @return array> Associative array of arrays mapping 678 | * argument names to argument values. 679 | */ 680 | protected function get_arguments(): array { 681 | return []; 682 | } 683 | 684 | /** 685 | * Get the shared instances for the dependency injector. 686 | * 687 | * These classes will only be instantiated once by the injector and then 688 | * reused on subsequent requests. 689 | * 690 | * This effectively turns them into singletons, without any of the 691 | * drawbacks of the actual Singleton anti-pattern. 692 | * 693 | * @return array Array of fully qualified class names. 694 | */ 695 | protected function get_shared_instances(): array { 696 | return []; 697 | } 698 | 699 | /** 700 | * Get the delegations for the dependency injector. 701 | * 702 | * These are basically factories to provide custom instantiation logic for 703 | * classes. 704 | * 705 | * @return array Associative array of callables. 706 | */ 707 | protected function get_delegations(): array { 708 | return []; 709 | } 710 | 711 | /** 712 | * Maybe resolve a value that is a callable instead of a scalar. 713 | * 714 | * Values that are passed through this method can optionally be provided as 715 | * callables instead of direct values and will be evaluated when needed. 716 | * 717 | * @param mixed $value Value to potentially resolve. 718 | * @return mixed Resolved or unchanged value. 719 | */ 720 | protected function maybe_resolve( $value ) { 721 | if ( is_callable( $value ) ) { 722 | return $value( $this->injector, $this->service_container ); 723 | } 724 | 725 | return $value; 726 | } 727 | 728 | /** 729 | * The collect_missing_dependencies() method is a helper for the dependency 730 | * resolution process. It returns an array of service IDs that are required by 731 | * the current service but not yet registered. 732 | * 733 | * Note: This is different from requirements in that dependencies are always 734 | * other services, while requirements can be arbitrary conditions. 735 | * 736 | * @param class-string $class_name Service class name of the service with dependencies. 737 | * @param array $services List of services to register. 738 | * 739 | * @throws InvalidService If the required service is not recognized. 740 | * 741 | * @return array List of missing dependencies as a 742 | * $service_id => $service_class mapping. 743 | */ 744 | protected function collect_missing_dependencies( string $class_name, array $services ): array { 745 | if ( ! is_a( $class_name, HasDependencies::class, true ) ) { 746 | return []; 747 | } 748 | 749 | $dependencies = $class_name::get_dependencies(); 750 | $missing = []; 751 | 752 | foreach ( $dependencies as $dependency ) { 753 | // Bail if it depends on a service that is not recognized. 754 | if ( ! array_key_exists( $dependency, $services ) ) { 755 | throw InvalidService::from_service_id( $dependency ); 756 | } 757 | 758 | if ( $this->service_container->has( $dependency ) ) { 759 | continue; 760 | } 761 | 762 | $missing[ $dependency ] = $services[ $dependency ]; 763 | } 764 | 765 | return $missing; 766 | } 767 | 768 | /** 769 | * Determine if the dependencies for a service to be registered are met. 770 | * 771 | * @param string $id Service ID of the service with dependencies. 772 | * @param class-string $class_name Service class name of the service with dependencies. 773 | * @param array $services List of services to be registered. 774 | * 775 | * @throws InvalidService If the required service is not recognized. 776 | * 777 | * @return bool Whether the dependencies for the service have been met. 778 | */ 779 | protected function dependencies_are_met( string $id, string $class_name, array &$services ): bool { 780 | $missing_dependencies = $this->collect_missing_dependencies( $class_name, $services ); 781 | 782 | if ( empty( $missing_dependencies ) ) { 783 | return true; 784 | } 785 | 786 | $registration_actions = []; 787 | foreach ( $missing_dependencies as $dependency_id => $dependency_class ) { 788 | $resolved_dependency_class = $this->maybe_resolve( $dependency_class ); 789 | 790 | if ( ! is_string( $resolved_dependency_class ) ) { 791 | throw InvalidService::from_invalid_identifier( $dependency_id ); 792 | } 793 | 794 | /** 795 | * The resolved value is guaranteed to be a string at this point. 796 | * 797 | * @var class-string $resolved_dependency_class 798 | */ 799 | 800 | if ( $resolved_dependency_class !== $dependency_class ) { 801 | $services[ $dependency_id ] = $resolved_dependency_class; 802 | $dependency_class = $resolved_dependency_class; 803 | } 804 | 805 | // Check if dependency is delayed. 806 | if ( is_a( $dependency_class, Delayed::class, true ) ) { 807 | $action = $dependency_class::get_registration_action(); 808 | 809 | if ( ! \did_action( $action ) ) { 810 | $registration_actions[ $action ][] = [ 811 | 'id' => $dependency_id, 812 | 'class' => $dependency_class, 813 | ]; 814 | } 815 | } 816 | } 817 | 818 | // If we have delayed dependencies, schedule registration after they're loaded. 819 | if ( ! empty( $registration_actions ) ) { 820 | foreach ( $registration_actions as $action => $dependencies ) { 821 | \add_action( 822 | $action, 823 | function () use ( $id, $class_name, $services, $dependencies ): void { 824 | // Check if all dependencies from this action are now available. 825 | foreach ( $dependencies as $dependency ) { 826 | if ( ! $this->service_container->has( $dependency['id'] ) ) { 827 | return; 828 | } 829 | } 830 | 831 | // Recheck all dependencies in case there are others. 832 | if ( $this->dependencies_are_met( $id, $class_name, $services ) ) { 833 | $this->maybe_register_service( $id, $class_name ); 834 | } 835 | }, 836 | PHP_INT_MAX, 837 | 0 838 | ); 839 | } 840 | return false; 841 | } 842 | 843 | // Move this service to the end of the services array since its dependencies 844 | // haven't been registered yet but will be encountered later. 845 | unset( $services[ $id ] ); 846 | $services[ $id ] = $class_name; 847 | 848 | return false; 849 | } 850 | } 851 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceContainer.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | use MWPD\BasicScaffold\Exception\InvalidService; 17 | use ArrayAccess; 18 | use Countable; 19 | use Traversable; 20 | 21 | /** 22 | * The service container collects all services to manage them. 23 | * 24 | * This is based on PSR-11 and should extend that one if Composer dependencies 25 | * are being used. Relying on a standardized interface like PSR-11 means you'll 26 | * be able to easily swap out the implementation for something else later on. 27 | * 28 | * @see https://www.php-fig.org/psr/psr-11/ 29 | * 30 | * @extends Traversable 31 | * @extends ArrayAccess 32 | */ 33 | interface ServiceContainer extends Traversable, Countable, ArrayAccess { 34 | 35 | /** 36 | * Find a service of the container by its identifier and return it. 37 | * 38 | * @param string $id Identifier of the service to look for. 39 | * 40 | * @throws InvalidService If the service could not be found. 41 | * 42 | * @return Service Service that was requested. 43 | */ 44 | public function get( string $id ): Service; 45 | 46 | /** 47 | * Check whether the container can return a service for the given 48 | * identifier. 49 | * 50 | * @param string $id Identifier of the service to look for. 51 | */ 52 | public function has( string $id ): bool; 53 | 54 | /** 55 | * Put a service into the container for later retrieval. 56 | * 57 | * @param string $id Identifier of the service to put into the 58 | * container. 59 | * @param Service $service Service to put into the container. 60 | */ 61 | public function put( string $id, Service $service ): void; 62 | } 63 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceContainer/LazilyInstantiatedService.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\ServiceContainer; 15 | 16 | use MWPD\BasicScaffold\Exception\InvalidService; 17 | use MWPD\BasicScaffold\Infrastructure\Service; 18 | 19 | /** 20 | * A service that only gets properly instantiated when it is actually being 21 | * retrieved from the container. 22 | */ 23 | final class LazilyInstantiatedService implements Service { 24 | 25 | /** 26 | * Instantiation callable. 27 | * 28 | * @var callable 29 | */ 30 | private $instantiation; 31 | 32 | /** 33 | * Instantiate a LazilyInstantiatedService object. 34 | * 35 | * @param callable $instantiation Instantiation callable to use. 36 | */ 37 | public function __construct( callable $instantiation ) { 38 | $this->instantiation = $instantiation; 39 | } 40 | 41 | /** 42 | * Do the actual service instantiation and return the real service. 43 | * 44 | * @throws InvalidService If the service could not be properly instantiated. 45 | * 46 | * @return Service Properly instantiated service. 47 | */ 48 | public function instantiate(): Service { 49 | $service = ( $this->instantiation )(); 50 | 51 | if ( ! $service instanceof Service ) { 52 | throw InvalidService::from_lazy_service( $service ); 53 | } 54 | 55 | return $service; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Infrastructure/ServiceContainer/SimpleServiceContainer.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\ServiceContainer; 15 | 16 | use MWPD\BasicScaffold\Exception\InvalidService; 17 | use MWPD\BasicScaffold\Infrastructure\Service; 18 | use MWPD\BasicScaffold\Infrastructure\ServiceContainer; 19 | use ArrayObject; 20 | 21 | /** 22 | * A simplified implementation of a service container. 23 | * 24 | * We extend ArrayObject so we have default implementations for iterators and 25 | * array access. 26 | * 27 | * @extends ArrayObject 28 | */ 29 | final class SimpleServiceContainer extends ArrayObject implements ServiceContainer { 30 | 31 | /** 32 | * Find a service of the container by its identifier and return it. 33 | * 34 | * @param string $id Identifier of the service to look for. 35 | * 36 | * @throws InvalidService If the service could not be found. 37 | * 38 | * @return Service Service that was requested. 39 | */ 40 | public function get( string $id ): Service { 41 | if ( ! $this->has( $id ) ) { 42 | throw InvalidService::from_service_id( $id ); 43 | } 44 | 45 | /** 46 | * The offsetGet method returns null if the key does not exist. 47 | * 48 | * @var Service|null 49 | */ 50 | $service = $this->offsetGet( $id ); 51 | 52 | if ( null === $service ) { 53 | throw InvalidService::from_service_id( $id ); 54 | } 55 | 56 | // Instantiate actual services if they were stored lazily. 57 | if ( $service instanceof LazilyInstantiatedService ) { 58 | $service = $service->instantiate(); 59 | $this->put( $id, $service ); 60 | } 61 | 62 | if ( ! $service instanceof Service ) { 63 | throw InvalidService::from_service_id( $id ); 64 | } 65 | 66 | return $service; 67 | } 68 | 69 | /** 70 | * Check whether the container can return a service for the given 71 | * identifier. 72 | * 73 | * @param string $id Identifier of the service to look for. 74 | */ 75 | public function has( string $id ): bool { 76 | return $this->offsetExists( $id ); 77 | } 78 | 79 | /** 80 | * Put a service into the container for later retrieval. 81 | * 82 | * @param string $id Identifier of the service to put into the 83 | * container. 84 | * @param Service $service Service to put into the container. 85 | */ 86 | public function put( string $id, Service $service ): void { 87 | $this->offsetSet( $id, $service ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Infrastructure/View.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | use stdClass; 17 | use MWPD\BasicScaffold\Exception\FailedToLoadView; 18 | use MWPD\BasicScaffold\Exception\InvalidPath; 19 | 20 | /** 21 | * The view interface defines how the rendering system works. 22 | * 23 | * When you render a view, you can pass a "context" information to it. This 24 | * context information is that made available to the scope in which the view 25 | * template is being rendered. 26 | * 27 | * As an example, with a default PHP-based view, the context information will be 28 | * available as properties of the '$this' variable. 29 | * 30 | * @phpstan-require-extends stdClass 31 | */ 32 | interface View extends Renderable { 33 | 34 | /** 35 | * Render the current view with a given context. 36 | * 37 | * @param array $context Context in which to render. 38 | * 39 | * @return string Rendered HTML. 40 | * @throws FailedToLoadView If the view could not be loaded. 41 | */ 42 | public function render( array $context = [] ): string; 43 | 44 | /** 45 | * Render a partial view. 46 | * 47 | * This can be used from within a currently rendered view, to include 48 | * nested partials. 49 | * 50 | * The passed-in context is optional, and will fall back to the parent's 51 | * context if omitted. 52 | * 53 | * @param string $path Path of the partial to render. 54 | * @param array|null $context Context in which to render the partial. 55 | * 56 | * @return string Rendered HTML. 57 | * @throws InvalidPath If the provided path was not valid. 58 | * @throws FailedToLoadView If the view could not be loaded. 59 | */ 60 | public function render_partial( string $path, array $context = null ): string; 61 | 62 | /** 63 | * Return the raw value of a context property. 64 | * 65 | * By default, properties are automatically escaped when accessing them 66 | * within the view. This method allows direct access to the raw value 67 | * instead to bypass this automatic escaping. 68 | * 69 | * @param string $property Property for which to return the raw value. 70 | * @return mixed Raw context property value. 71 | */ 72 | public function raw( $property ); 73 | 74 | /** 75 | * Return the escaped value of a context property. 76 | * 77 | * Use the raw() method to skip automatic escaping. 78 | * 79 | * @param string $property Property for which to return the escaped value. 80 | * @return string Escaped context property value. 81 | */ 82 | public function __get( string $property ); 83 | } 84 | -------------------------------------------------------------------------------- /src/Infrastructure/View/SimpleView.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\View; 15 | 16 | use MWPD\BasicScaffold\Exception\FailedToEscapeValue; 17 | use MWPD\BasicScaffold\Exception\FailedToLoadView; 18 | use MWPD\BasicScaffold\Exception\InvalidContextProperty; 19 | use MWPD\BasicScaffold\Exception\InvalidPath; 20 | use MWPD\BasicScaffold\Infrastructure\View; 21 | use MWPD\BasicScaffold\Infrastructure\ViewFactory; 22 | use MWPD\BasicScaffold\Infrastructure\Service\DebugMode; 23 | use MWPD\BasicScaffold\Infrastructure\Service\WordPressDebugMode; 24 | use stdClass; 25 | use Throwable; 26 | 27 | /** 28 | * A simplified implementation of a renderable view object. 29 | * 30 | * This extends stdClass to get around a deprecation notice in PHP 8.2. 31 | * 32 | * @see https://php.watch/versions/8.2/dynamic-properties-deprecated#stdClass 33 | */ 34 | class SimpleView extends stdClass implements View { 35 | 36 | /** 37 | * Extension to use for view files. 38 | * 39 | * @var string 40 | */ 41 | protected const VIEW_EXTENSION = 'php'; 42 | 43 | /** 44 | * Path to the view file to render. 45 | */ 46 | protected string $path; 47 | 48 | /** 49 | * Internal storage for passed-in context. 50 | * 51 | * @var array 52 | */ 53 | protected $_context_ = []; 54 | 55 | /** 56 | * View factory instance to use. 57 | */ 58 | protected ViewFactory $view_factory; 59 | 60 | /** 61 | * Debug mode instance to use. 62 | */ 63 | private DebugMode $debug_mode; 64 | 65 | /** 66 | * Instantiate a SimpleView object. 67 | * 68 | * @param string $path Path to the view file to render. 69 | * @param ViewFactory $view_factory View factory instance to use. 70 | * @param ?DebugMode $debug_mode Debug mode instance to use. Optional, defaults to WordPressDebugMode. 71 | * @throws InvalidPath If an invalid Path was passed into the View. 72 | */ 73 | public function __construct( string $path, ViewFactory $view_factory, ?DebugMode $debug_mode = null ) { 74 | $this->path = $this->validate( $path ); 75 | $this->view_factory = $view_factory; 76 | $this->debug_mode = $debug_mode ?? new WordPressDebugMode(); 77 | } 78 | 79 | /** 80 | * Render the current view with a given context. 81 | * 82 | * @param array $context Context in which to render. 83 | * 84 | * @return string Rendered HTML. 85 | * @throws FailedToLoadView If the View path could not be loaded. 86 | */ 87 | public function render( array $context = [] ): string { 88 | // Add entire context as array to the current instance to pass onto 89 | // partial views. 90 | $this->_context_ = $context; 91 | 92 | // Save current buffering level so we can backtrack in case of an error. 93 | // This is needed because the view itself might also add an unknown 94 | // number of output buffering levels. 95 | $buffer_level = \ob_get_level(); 96 | \ob_start(); 97 | 98 | try { 99 | /** 100 | * This include cannot be followed to be statically analyzed. 101 | * 102 | * @psalm-suppress UnresolvableInclude 103 | */ 104 | include $this->path; 105 | } catch ( Throwable $throwable ) { 106 | // Remove whatever levels were added up until now. 107 | while ( \ob_get_level() > $buffer_level ) { 108 | \ob_end_clean(); 109 | } 110 | 111 | throw FailedToLoadView::from_view_exception( 112 | $this->path, 113 | $throwable 114 | ); 115 | } 116 | 117 | $buffer = \ob_get_clean(); 118 | 119 | return false === $buffer ? '' : $buffer; 120 | } 121 | 122 | /** 123 | * Render a partial view. 124 | * 125 | * This can be used from within a currently rendered view, to include 126 | * nested partials. 127 | * 128 | * The passed-in context is optional, and will fall back to the parent's 129 | * context if omitted. 130 | * 131 | * @param string $path Path of the partial to render. 132 | * @param array|null $context Context in which to render the partial. 133 | * 134 | * @return string Rendered HTML. 135 | * @throws InvalidPath If the provided path was not valid. 136 | * @throws FailedToLoadView If the view could not be loaded. 137 | */ 138 | public function render_partial( string $path, array $context = null ): string { 139 | return $this->view_factory->create( $path ) 140 | ->render( $context ?? $this->_context_ ); 141 | } 142 | 143 | /** 144 | * Return the raw value of a context property. 145 | * 146 | * By default, properties are automatically escaped when accessing them 147 | * within the view. This method allows direct access to the raw value 148 | * instead to bypass this automatic escaping. 149 | * 150 | * @param string $property Property for which to return the raw value. 151 | * @return mixed Raw context property value. 152 | * @throws InvalidContextProperty If the property does not exist (in debug mode). 153 | */ 154 | public function raw( $property ) { 155 | if ( array_key_exists( $property, $this->_context_ ) ) { 156 | return $this->_context_[ $property ]; 157 | } 158 | 159 | /* 160 | * We only throw an exception here if we are in debugging mode, as we 161 | * don't want to take the server down when trying to render a missing 162 | * property. 163 | */ 164 | if ( $this->is_debug_mode() ) { 165 | throw InvalidContextProperty::from_property( $property ); 166 | } 167 | 168 | return null; 169 | } 170 | 171 | /** 172 | * Validate a path. 173 | * 174 | * @param string $path Path to validate. 175 | * 176 | * @return string Validated path. 177 | * @throws InvalidPath If an invalid path was passed into the View. 178 | */ 179 | protected function validate( string $path ): string { 180 | $path = $this->check_extension( $path, static::VIEW_EXTENSION ); 181 | $path = $this->ensure_trailing_slash( \dirname( __DIR__, 3 ) ) . $path; 182 | 183 | if ( ! \is_readable( $path ) ) { 184 | throw InvalidPath::from_path( $path ); 185 | } 186 | 187 | return $path; 188 | } 189 | 190 | /** 191 | * Check that the path has the correct extension. 192 | * 193 | * Optionally adds the extension if none was detected. 194 | * 195 | * @param string $path Path to check the extension of. 196 | * @param string $extension Extension to use. 197 | * 198 | * @return string Path with correct extension. 199 | */ 200 | protected function check_extension( string $path, string $extension ): string { 201 | $detected_extension = \pathinfo( $path, PATHINFO_EXTENSION ); 202 | 203 | if ( $extension !== $detected_extension ) { 204 | $path .= '.' . $extension; 205 | } 206 | 207 | return $path; 208 | } 209 | 210 | /** 211 | * Ensure the path has a trailing slash. 212 | * 213 | * @param string $path Path to maybe add a trailing slash. 214 | * 215 | * @return string Path with trailing slash. 216 | */ 217 | protected function ensure_trailing_slash( string $path ): string { 218 | return \rtrim( $path, '/\\' ) . '/'; 219 | } 220 | 221 | /** 222 | * Return the escaped value of a context property. 223 | * 224 | * Use the raw() method to skip automatic escaping. 225 | * 226 | * @param string $property Property for which to return the escaped value. 227 | * @return string Escaped context property value. 228 | * @throws InvalidContextProperty If the property does not exist (in debug mode). 229 | */ 230 | public function __get( string $property ) { 231 | if ( array_key_exists( $property, $this->_context_ ) ) { 232 | $value = $this->_context_[ $property ]; 233 | 234 | if ( $this->is_stringable( $value ) ) { 235 | return $this->escape( $value ); 236 | } 237 | } 238 | 239 | /* 240 | * We only throw an exception here if we are in debugging mode, as we 241 | * don't want to take the server down when trying to render a missing 242 | * property. 243 | */ 244 | if ( $this->is_debug_mode() ) { 245 | throw InvalidContextProperty::from_property( $property ); 246 | } 247 | 248 | // Return an empty string if the property does not exist. 249 | return ''; 250 | } 251 | 252 | /** 253 | * Escape a value for output. 254 | * 255 | * @param mixed $value Value to escape. 256 | * @return string Escaped value. 257 | * @throws FailedToEscapeValue If the value could not be escaped. 258 | */ 259 | protected function escape( $value ): string { 260 | if ( is_object( $value ) && method_exists( $value, '__toString' ) ) { 261 | $value = (string) $value; 262 | } 263 | 264 | if ( is_scalar( $value ) ) { 265 | $value = (string) $value; 266 | } 267 | 268 | if ( ! is_string( $value ) ) { 269 | throw FailedToEscapeValue::from_value( $value ); 270 | } 271 | 272 | return htmlspecialchars( $value, ENT_COMPAT, 'UTF-8' ); 273 | } 274 | 275 | /** 276 | * Check whether debugging mode is enabled. 277 | * 278 | * @return bool Whether debugging mode is enabled. 279 | */ 280 | protected function is_debug_mode(): bool { 281 | return $this->debug_mode->is_debug_mode(); 282 | } 283 | 284 | /** 285 | * Check if a value is stringable. 286 | * 287 | * @param mixed $value Value to check. 288 | * @return bool Whether the value is stringable. 289 | */ 290 | protected function is_stringable( $value ): bool { 291 | return is_scalar( $value ) 292 | || ( is_object( $value ) && method_exists( $value, '__toString' ) ); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Infrastructure/View/SimpleViewFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\View; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\Service; 17 | use MWPD\BasicScaffold\Infrastructure\View; 18 | use MWPD\BasicScaffold\Infrastructure\ViewFactory; 19 | 20 | /** 21 | * Factory to create the simplified view objects. 22 | */ 23 | final class SimpleViewFactory implements Service, ViewFactory { 24 | 25 | /** 26 | * Create a new view object for a given relative path. 27 | * 28 | * @param string $relative_path Relative path to create the view for. 29 | * 30 | * @return SimpleView Instantiated view object. 31 | */ 32 | public function create( string $relative_path ): View { 33 | return new SimpleView( $relative_path, $this ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Infrastructure/View/TemplatedView.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\View; 15 | 16 | use MWPD\BasicScaffold\Exception\InvalidPath; 17 | use MWPD\BasicScaffold\Infrastructure\ViewFactory; 18 | 19 | /** 20 | * A templated variation of the simplified view object. 21 | * 22 | * It has an ordered list of locations and traverses these until it finds a 23 | * matching view. 24 | */ 25 | final class TemplatedView extends SimpleView { 26 | 27 | /** 28 | * Array of locations to use. 29 | * 30 | * @var array 31 | */ 32 | private array $locations; 33 | 34 | /** 35 | * Instantiate a TemplatedView object. 36 | * 37 | * @param string $path Path to the view file to render. 38 | * @param ViewFactory $view_factory View factory instance to use. 39 | * @param array $locations Optional. Array of locations to use. 40 | */ 41 | public function __construct( 42 | string $path, 43 | ViewFactory $view_factory, 44 | array $locations = [] 45 | ) { 46 | $this->locations = array_map( [ $this, 'ensure_trailing_slash' ], $locations ); 47 | parent::__construct( $path, $view_factory ); 48 | } 49 | 50 | /** 51 | * Add a location to the templated view. 52 | * 53 | * @param string $location Location to add. 54 | * @return self Modified templated view. 55 | */ 56 | public function add_location( string $location ): self { 57 | $this->locations[] = $this->ensure_trailing_slash( $location ); 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * Validate a path. 64 | * 65 | * @param string $path Path to validate. 66 | * 67 | * @return string Validated Path. 68 | * @throws InvalidPath If an invalid path was passed into the View. 69 | */ 70 | protected function validate( string $path ): string { 71 | $path = $this->check_extension( $path, self::VIEW_EXTENSION ); 72 | 73 | foreach ( $this->get_locations( $path ) as $location ) { 74 | if ( \is_readable( $location ) ) { 75 | return $location; 76 | } 77 | } 78 | 79 | if ( ! \is_readable( $path ) ) { 80 | throw InvalidPath::from_path( $path ); 81 | } 82 | 83 | return $path; 84 | } 85 | 86 | /** 87 | * Get the possible locations for the view. 88 | * 89 | * @param string $path Path of the view to get the locations for. 90 | * 91 | * @return array Array of possible locations. 92 | */ 93 | private function get_locations( string $path ): array { 94 | return array_map( 95 | fn( string $location ): string => $location . $path, 96 | $this->locations 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Infrastructure/View/TemplatedViewFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure\View; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\Service; 17 | use MWPD\BasicScaffold\Infrastructure\View; 18 | use MWPD\BasicScaffold\Infrastructure\ViewFactory; 19 | 20 | /** 21 | * A factory to create templated views. 22 | * 23 | * If you don't provide the optional locations array, it will default to (in 24 | * this exact order): 25 | * 1. child theme folder 26 | * 2. parent theme folder 27 | * 3. plugin folder 28 | */ 29 | final class TemplatedViewFactory implements Service, ViewFactory { 30 | 31 | /** 32 | * Array of locations to use. 33 | * 34 | * @var array 35 | */ 36 | private array $locations; 37 | 38 | /** 39 | * Instantiate a TemplatedViewFactory object. 40 | * 41 | * @param array $locations Array of locations to use. 42 | */ 43 | public function __construct( array $locations = [] ) { 44 | if ( $locations === [] ) { 45 | $locations = $this->get_default_locations(); 46 | } 47 | 48 | $this->locations = $locations; 49 | } 50 | 51 | /** 52 | * Create a new view object for a given relative path. 53 | * 54 | * @param string $relative_path Relative path to create the view for. 55 | * 56 | * @return TemplatedView Instantiated view object. 57 | */ 58 | public function create( string $relative_path ): View { 59 | return new TemplatedView( $relative_path, $this, $this->locations ); 60 | } 61 | 62 | /** 63 | * Get the default locations for the templated view. 64 | * 65 | * Uses internal caching to avoid retrieving the paths multiple times across 66 | * instantiations. 67 | * 68 | * @return array Array of default locations. 69 | */ 70 | private function get_default_locations(): array { 71 | /** 72 | * Internal storage for the default locations. 73 | */ 74 | static $default_locations = null; 75 | 76 | if ( null === $default_locations ) { 77 | // We wrap the WP functions here to not make the code directly rely 78 | // on WordPress being loaded here. 79 | // This makes the code more flexible and testing easier. 80 | $default_locations = ( function_exists( 'get_stylesheet_directory' ) 81 | && function_exists( 'get_template_directory' ) ) 82 | ? [ 83 | \get_stylesheet_directory(), 84 | \get_template_directory(), 85 | \dirname( __DIR__, 3 ), 86 | ] 87 | : [ \dirname( __DIR__, 3 ) ]; 88 | } 89 | 90 | return $default_locations; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Infrastructure/ViewFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Infrastructure; 15 | 16 | /** 17 | * The ViewFactory interface is the main access point to the rendering system. 18 | * 19 | * The way this works is that you declare a dependency on the ViewFactory 20 | * interface in the constructor of a class that needs to render something. If 21 | * this class is instantiated through the dependency injector, it will receive 22 | * whatever concrete ViewFactory has been configured and can just use that one 23 | * to create one or more view and subsequently render them. 24 | */ 25 | interface ViewFactory { 26 | 27 | /** 28 | * Create a new view object for a given relative path. 29 | * 30 | * @param string $relative_path Relative path to create the view for. 31 | * @return View Instantiated view object. 32 | */ 33 | public function create( string $relative_path ): View; 34 | } 35 | -------------------------------------------------------------------------------- /src/SampleSubsystem/SampleBackendService.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\SampleSubsystem; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\{ 17 | Conditional, 18 | Registerable, 19 | Service, 20 | ViewFactory 21 | }; 22 | 23 | /** 24 | * This sample service only renders a silly "Hello World" notice in the admin 25 | * backend. 26 | * 27 | * It is meant to illustrate how to hook services into the plugin flow 28 | * and how to have their dependencies by injected. 29 | * 30 | * Note that the dependency here is actually an interface, not a class. We can 31 | * still just transparently use it though. 32 | */ 33 | final class SampleBackendService implements Service, Registerable, Conditional { 34 | 35 | /** 36 | * View factory. 37 | */ 38 | private ViewFactory $view_factory; 39 | 40 | /** 41 | * Check whether the conditional service is currently needed. 42 | * 43 | * @return bool Whether the conditional service is needed. 44 | */ 45 | public static function is_needed(): bool { 46 | /* 47 | * We only load this sample service on the admin backend. 48 | * If this conditional returns false, the service is never even 49 | * instantiated. 50 | */ 51 | return \is_admin() && ! \wp_doing_ajax(); 52 | } 53 | 54 | /** 55 | * Instantiate a SampleBackendService object. 56 | * 57 | * @param ViewFactory $view_factory View factory to use for instantiating 58 | * the views. 59 | */ 60 | public function __construct( ViewFactory $view_factory ) { 61 | /* 62 | * We request a view factory from the injector so that we can create a 63 | * new view to be rendered when we want to show our sample notice. 64 | */ 65 | $this->view_factory = $view_factory; 66 | } 67 | 68 | /** 69 | * Register the service. 70 | */ 71 | public function register(): void { 72 | /* 73 | * The register method now hooks our actual sample functionality into 74 | * the WordPress execution flow. 75 | */ 76 | \add_action( 'admin_notices', [ $this, 'render_notice' ], 10, 0 ); 77 | } 78 | 79 | /** 80 | * Render the admin notice. 81 | */ 82 | public function render_notice(): void { 83 | /* 84 | * As we already have an instance of the view factory available, it is 85 | * now easy to create a new view and render it. 86 | */ 87 | echo $this->view_factory->create( 'views/test-backend-service' ) // phpcs:ignore WordPress.Security.EscapeOutput,Generic.Files.LineLength 88 | ->render( [ 'plugin' => 'MWPD Boilerplate' ] ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/SampleSubsystem/SampleLoopService.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\SampleSubsystem; 15 | 16 | use MWPD\BasicScaffold\Infrastructure\{ 17 | Conditional, 18 | Delayed, 19 | Registerable, 20 | Service, 21 | ViewFactory 22 | }; 23 | use WP_Post; 24 | 25 | /** 26 | * Sample loop service. 27 | */ 28 | final class SampleLoopService implements Service, Registerable, Conditional, Delayed { 29 | 30 | /** 31 | * We only want to register this service once the loop has been set up, as 32 | * we want to use smart injection to retrieve the current post. 33 | * 34 | * @var non-empty-string 35 | */ 36 | private const REGISTRATION_HOOK = 'wp'; 37 | 38 | /** 39 | * View factory. 40 | */ 41 | private ViewFactory $view_factory; 42 | 43 | /** 44 | * Post. 45 | * 46 | * @var WP_Post 47 | */ 48 | private $post; 49 | 50 | /** 51 | * Check whether the conditional service is currently needed. 52 | * 53 | * @return bool Whether the conditional service is needed. 54 | */ 55 | public static function is_needed(): bool { 56 | /* 57 | * We only load this sample service on the frontend when a singular post 58 | * is shown. 59 | * If this conditional returns false, the service is never even 60 | * instantiated. 61 | */ 62 | return \is_singular(); 63 | } 64 | 65 | /** 66 | * Get the action to use for registering the service. 67 | * 68 | * @return non-empty-string Registration action to use. 69 | */ 70 | public static function get_registration_action(): string { 71 | return self::REGISTRATION_HOOK; 72 | } 73 | 74 | /** 75 | * Instantiate a SampleLoopService object. 76 | * 77 | * @param ViewFactory $view_factory View factory to use for instantiating 78 | * the views. 79 | * @param WP_Post $post WordPress post object to use. 80 | */ 81 | public function __construct( ViewFactory $view_factory, WP_Post $post ) { 82 | /* 83 | * We request a view factory from the injector so that we can create a 84 | * new view to be rendered when we want to show our sample notice. 85 | */ 86 | $this->view_factory = $view_factory; 87 | 88 | /* 89 | * We also use the injector to retrieve the current post in the loop, to 90 | * demonstrate how delegation and delayed registration works. 91 | * 92 | * Although we use "up-front" dependency injection, we have our service 93 | * be registered in a delayed fashion to only do the actual injection 94 | * after the WordPress loop has been set up, a requirement for the 95 | * delegation to be able to retrieve the "current" post. 96 | */ 97 | $this->post = $post; 98 | } 99 | 100 | /** 101 | * Register the service. 102 | */ 103 | public function register(): void { 104 | /* 105 | * The register method now hooks our actual sample functionality into 106 | * the WordPress execution flow. 107 | */ 108 | \add_filter( 'the_content', [ $this, 'prepend_post_header' ] ); 109 | } 110 | 111 | /** 112 | * Prepend a post header to the content. 113 | * 114 | * @param string $content The content to be filtered. 115 | * @return string Filtered content prepended with a post header. 116 | */ 117 | public function prepend_post_header( string $content ): string { 118 | /* 119 | * As we already have an instance of the view factory available, it is 120 | * now easy to create a new view and render it. 121 | */ 122 | $post_header = $this->view_factory->create( 'views/test-loop-service' ) 123 | ->render( [ 'post' => $this->post ] ); 124 | 125 | return $post_header . $content; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/php/Fixture/DummyClass.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Fixture; 15 | 16 | final class DummyClass { 17 | 18 | } 19 | -------------------------------------------------------------------------------- /tests/php/Fixture/DummyClassWithDependency.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Fixture; 15 | 16 | final class DummyClassWithDependency implements DummyInterface { 17 | 18 | private DummyClass $dummy; 19 | 20 | public function __construct( DummyClass $dummy ) { 21 | $this->dummy = $dummy; 22 | } 23 | 24 | public function get_dummy(): DummyClass { 25 | return $this->dummy; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/php/Fixture/DummyClassWithNamedArguments.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Fixture; 15 | 16 | final class DummyClassWithNamedArguments { 17 | 18 | private int $argument_a; 19 | 20 | private string $argument_b; 21 | 22 | public function __construct( int $argument_a, string $argument_b = 'Mr Meeseeks' ) { 23 | $this->argument_a = $argument_a; 24 | $this->argument_b = $argument_b; 25 | } 26 | 27 | public function get_argument_a(): int { 28 | return $this->argument_a; 29 | } 30 | 31 | public function get_argument_b(): string { 32 | return $this->argument_b; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/php/Fixture/DummyInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Fixture; 15 | 16 | interface DummyInterface { 17 | 18 | public function get_dummy(): DummyClass; 19 | } 20 | -------------------------------------------------------------------------------- /tests/php/Fixture/Service/TestCircularA.php: -------------------------------------------------------------------------------- 1 | TestCircularA::class, 15 | 'circular_b' => TestCircularB::class, 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/php/Fixture/TestMissingDependencyPlugin.php: -------------------------------------------------------------------------------- 1 | TestServiceWithMissingDependency::class, 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/php/Fixture/TestMultipleDelayedDependenciesPlugin.php: -------------------------------------------------------------------------------- 1 | TestDelayedService1::class, 16 | 'delayed_service_2' => TestDelayedService2::class, 17 | 'dependent_service' => TestMultiDependentService::class, 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/php/Fixture/TestServiceBasedPlugin.php: -------------------------------------------------------------------------------- 1 | TestServiceA::class, 18 | 'service_b' => TestServiceB::class, 19 | 'service_c' => TestServiceC::class, 20 | 'delayed_service' => TestDelayedService::class, 21 | 'dependent_service' => TestDependentService::class, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/php/Fixture/TestServiceWithMissingDependency.php: -------------------------------------------------------------------------------- 1 | 8 | partial C from child theme - render_partial( 'partial-d' ) ?> 9 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/child_theme/view-c.php: -------------------------------------------------------------------------------- 1 |

View C comes from child theme.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/dynamic-view.php: -------------------------------------------------------------------------------- 1 | 8 |

Rendering works with context: some_value ?>.

9 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/parent_theme/partial-b.php: -------------------------------------------------------------------------------- 1 | 6 | partial B from parent theme - render_partial( 'partial-c' ) ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/parent_theme/partial-c.php: -------------------------------------------------------------------------------- 1 | 6 | partial C from parent theme - render_partial( 'partial-d' ) ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/parent_theme/partial-d.php: -------------------------------------------------------------------------------- 1 | 6 | partial D from parent theme - render_partial( 'partial-e' ) ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/parent_theme/view-b.php: -------------------------------------------------------------------------------- 1 |

View B comes from parent theme.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/parent_theme/view-c.php: -------------------------------------------------------------------------------- 1 |

View C comes from parent theme.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/partial.php: -------------------------------------------------------------------------------- 1 | 6 | some_value ?> 7 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/dynamic-view.php: -------------------------------------------------------------------------------- 1 | 6 |

Rendering works with context: some_value ?>.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial-a.php: -------------------------------------------------------------------------------- 1 | 6 | partial A from plugin - render_partial( 'partial-b' ) ?> 7 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial-b.php: -------------------------------------------------------------------------------- 1 | 6 | partial B from plugin - render_partial( 'partial-c' ) ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial-c.php: -------------------------------------------------------------------------------- 1 | 6 | partial C from plugin - render_partial( 'partial-d' ) ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial-d.php: -------------------------------------------------------------------------------- 1 | 6 | partial D from plugin - render_partial( 'partial-e' ) ?> 7 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial-e.php: -------------------------------------------------------------------------------- 1 | partial E from plugin -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/partial.php: -------------------------------------------------------------------------------- 1 | 6 | some_value ?> -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/static-view.php: -------------------------------------------------------------------------------- 1 |

Rendering works.

2 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/view-a.php: -------------------------------------------------------------------------------- 1 |

View A comes from plugin.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/view-b.php: -------------------------------------------------------------------------------- 1 |

View B comes from plugin.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/view-c.php: -------------------------------------------------------------------------------- 1 |

View C comes from plugin.

-------------------------------------------------------------------------------- /tests/php/Fixture/views/plugin/view-with-partial.php: -------------------------------------------------------------------------------- 1 | 6 |

Rendering works with partials: render_partial( 'partial' ) ?>.

7 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/static-view.php: -------------------------------------------------------------------------------- 1 |

Rendering works.

2 | -------------------------------------------------------------------------------- /tests/php/Fixture/views/view-with-partial.php: -------------------------------------------------------------------------------- 1 | 6 |

Rendering works with partials: render_partial( 'tests/php/Fixture/views/partial' ) ?>.

7 | -------------------------------------------------------------------------------- /tests/php/Integration/SimpleViewFactoryTest.php: -------------------------------------------------------------------------------- 1 | create( ViewHelper::VIEWS_FOLDER . 'static-view' ); 17 | $this->assertInstanceOf( SimpleView::class, $view ); 18 | } 19 | 20 | public function test_created_views_implement_the_interface(): void { 21 | $factory = new SimpleViewFactory(); 22 | 23 | $view = $factory->create( ViewHelper::VIEWS_FOLDER . 'static-view' ); 24 | $this->assertInstanceOf( View::class, $view ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/php/Integration/SimpleViewTest.php: -------------------------------------------------------------------------------- 1 | assertStringStartsWith( 19 | '

Rendering works.

', 20 | $view->render() 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/php/Integration/TemplatedViewFactoryTest.php: -------------------------------------------------------------------------------- 1 | create( 'static-view' ); 17 | $this->assertInstanceOf( TemplatedView::class, $view ); 18 | } 19 | 20 | public function test_created_views_implement_the_interface(): void { 21 | $factory = new TemplatedViewFactory( ViewHelper::LOCATIONS ); 22 | 23 | $view = $factory->create( 'static-view' ); 24 | $this->assertInstanceOf( View::class, $view ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/php/Integration/TemplatedViewTest.php: -------------------------------------------------------------------------------- 1 | assertStringStartsWith( 20 | 'partial A from plugin - partial B from parent theme - partial C from child theme - ' 21 | . 'partial D from parent theme - partial E from plugin', 22 | $partials->render() 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/php/Integration/TestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Integration; 15 | 16 | use Brain\Monkey; 17 | use Yoast\PHPUnitPolyfills\TestCases\TestCase as PHPUnitTestCase; 18 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; 19 | 20 | abstract class TestCase extends PHPUnitTestCase { 21 | 22 | // Adds Mockery expectations to the PHPUnit assertions count. 23 | use MockeryPHPUnitIntegration; 24 | 25 | protected function set_up() { 26 | parent::set_up(); 27 | Monkey\setUp(); 28 | } 29 | 30 | protected function tear_down() { 31 | Monkey\tearDown(); 32 | parent::tear_down(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/php/Unit/Exception/FailedToEscapeValueTest.php: -------------------------------------------------------------------------------- 1 | assertStringContainsString( '42', $result->getMessage() ); 18 | } 19 | 20 | public function test_from_value_with_object_with_to_string(): void { 21 | $value = new class() { 22 | public function __toString(): string { 23 | return 'custom string'; 24 | } 25 | }; 26 | 27 | $result = FailedToEscapeValue::from_value( $value ); 28 | 29 | $this->assertStringContainsString( 'custom string', $result->getMessage() ); 30 | } 31 | 32 | public function test_from_value_with_non_stringable_object(): void { 33 | $value = new stdClass(); 34 | 35 | $result = FailedToEscapeValue::from_value( $value ); 36 | 37 | $this->assertStringContainsString( '{object}', $result->getMessage() ); 38 | } 39 | 40 | public function test_from_value_with_array(): void { 41 | $value = [ 'test' ]; 42 | 43 | $result = FailedToEscapeValue::from_value( $value ); 44 | 45 | $this->assertStringContainsString( '{array}', $result->getMessage() ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/php/Unit/InjectionChainTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf( InjectionChain::class, $chain ); 14 | } 15 | 16 | public function test_it_accepts_new_resolutions(): void { 17 | $chain = ( new InjectionChain() ) 18 | ->add_resolution( 'something' ); 19 | 20 | $this->assertTrue( $chain->has_resolution( 'something' ) ); 21 | $this->assertFalse( $chain->has_resolution( 'something_else' ) ); 22 | } 23 | 24 | public function test_it_accepts_new_chain_entries(): void { 25 | $chain = ( new InjectionChain() ) 26 | ->add_to_chain( 'something' ); 27 | 28 | $this->assertEquals( 'something', $chain->get_class() ); 29 | } 30 | 31 | public function test_it_returns_the_last_class_in_the_chain(): void { 32 | $chain = ( new InjectionChain() ) 33 | ->add_to_chain( 'first' ) 34 | ->add_to_chain( 'second' ) 35 | ->add_to_chain( 'third' ); 36 | 37 | $this->assertEquals( 'third', $chain->get_class() ); 38 | } 39 | 40 | public function test_it_retains_all_elements_in_the_chain(): void { 41 | $chain = ( new InjectionChain() ) 42 | ->add_to_chain( 'first' ) 43 | ->add_to_chain( 'second' ) 44 | ->add_to_chain( 'third' ); 45 | 46 | $this->assertEquals( [ 'third', 'second', 'first' ], $chain->get_chain() ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/php/Unit/ServiceBasedPluginTest.php: -------------------------------------------------------------------------------- 1 | register_services(); 29 | 30 | $container = $plugin->get_container(); 31 | 32 | $this->assertTrue( $container->has( 'service_a' ) ); 33 | $this->assertTrue( $container->has( 'service_b' ) ); 34 | $this->assertTrue( $container->has( 'service_c' ) ); 35 | 36 | $this->assertInstanceOf( TestServiceA::class, $container->get( 'service_a' ) ); 37 | $this->assertInstanceOf( TestServiceB::class, $container->get( 'service_b' ) ); 38 | $this->assertInstanceOf( TestServiceC::class, $container->get( 'service_c' ) ); 39 | } 40 | 41 | /** 42 | * Test that delayed dependencies are properly handled. 43 | */ 44 | public function test_delayed_dependencies_are_properly_handled(): void { 45 | $plugin = new TestServiceBasedPlugin(); 46 | $plugin->register(); 47 | $this->assertNotFalse( has_action( 'plugins_loaded', [ $plugin, 'register_services' ] ) ); 48 | 49 | do_action( 'plugins_loaded' ); 50 | 51 | // Before init, delayed service should not be registered yet. 52 | $this->assertFalse( $plugin->get_container()->has( 'delayed_service' ) ); 53 | 54 | // TODO (AS): Below steps cannot be tested yet without actually executing the actions. 55 | // $this->assertNotFalse( has_action( 'init', 'function ()' ) ); 56 | 57 | // do_action( 'init' ); 58 | 59 | // After init, delayed service should be registered now. 60 | // $this->assertTrue( $plugin->get_container()->has( 'delayed_service' ) ); 61 | // $this->assertTrue( $plugin->get_container()->has( 'dependent_service' ) ); 62 | } 63 | 64 | /** 65 | * Test that circular dependencies are detected. 66 | */ 67 | public function test_circular_dependencies_are_detected(): void { 68 | $plugin = new TestCircularDependencyPlugin(); 69 | $plugin->register_services(); 70 | 71 | // The services should not be registered due to circular dependency. 72 | $this->assertFalse( $plugin->get_container()->has( 'circular_a' ) ); 73 | $this->assertFalse( $plugin->get_container()->has( 'circular_b' ) ); 74 | } 75 | 76 | /** 77 | * Test that missing dependencies throw an exception. 78 | */ 79 | public function test_missing_dependencies_throw_exception(): void { 80 | $this->expectException( \MWPD\BasicScaffold\Exception\InvalidService::class ); 81 | 82 | $plugin = new TestMissingDependencyPlugin(); 83 | $plugin->register_services(); 84 | } 85 | 86 | /** 87 | * Test that multiple delayed dependencies are handled correctly. 88 | */ 89 | public function test_multiple_delayed_dependencies(): void { 90 | $plugin = new TestMultipleDelayedDependenciesPlugin(); 91 | $plugin->register_services(); 92 | 93 | $this->assertFalse( $plugin->get_container()->has( 'delayed_service_1' ) ); 94 | $this->assertFalse( $plugin->get_container()->has( 'delayed_service_2' ) ); 95 | $this->assertFalse( $plugin->get_container()->has( 'dependent_service' ) ); 96 | 97 | // TODO (AS): Below steps cannot be tested yet without actually executing the actions. 98 | 99 | // First delayed dependency registers now. 100 | // do_action( 'init' ); 101 | // $this->assertTrue( $plugin->get_container()->has( 'delayed_service_1' ) ); 102 | // $this->assertFalse( $plugin->get_container()->has( 'delayed_service_2' ) ); 103 | // $this->assertFalse( $plugin->get_container()->has( 'dependent_service' ) ); 104 | 105 | // Second delayed dependency registers now. 106 | // do_action( 'wp_loaded' ); 107 | // $this->assertTrue( $plugin->get_container()->has( 'delayed_service_2' ) ); 108 | // $this->assertTrue( $plugin->get_container()->has( 'dependent_service' ) ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/php/Unit/SimpleInjectorTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf( SimpleInjector::class, $injector ); 22 | } 23 | 24 | public function test_it_implements_the_interface(): void { 25 | $injector = new SimpleInjector(); 26 | 27 | $this->assertInstanceOf( Injector::class, $injector ); 28 | } 29 | 30 | public function test_it_can_instantiate_a_concrete_class(): void { 31 | $object = ( new SimpleInjector() ) 32 | ->make( DummyClass::class ); 33 | 34 | $this->assertInstanceOf( DummyClass::class, $object ); 35 | } 36 | 37 | public function test_it_can_autowire_a_class_with_a_dependency(): void { 38 | $object = ( new SimpleInjector() ) 39 | ->make( DummyClassWithDependency::class ); 40 | 41 | $this->assertInstanceOf( DummyClassWithDependency::class, $object ); 42 | $this->assertInstanceOf( DummyClass::class, $object->get_dummy() ); 43 | } 44 | 45 | public function test_it_can_instantiate_a_bound_interface(): void { 46 | $injector = ( new SimpleInjector() ) 47 | ->bind( 48 | DummyInterface::class, 49 | DummyClassWithDependency::class 50 | ); 51 | $object = $injector->make( DummyInterface::class ); 52 | 53 | $this->assertInstanceOf( DummyInterface::class, $object ); 54 | $this->assertInstanceOf( DummyClassWithDependency::class, $object ); 55 | $this->assertInstanceOf( DummyClass::class, $object->get_dummy() ); 56 | } 57 | 58 | public function test_it_returns_separate_instances_by_default(): void { 59 | $injector = new SimpleInjector(); 60 | $object_a = $injector->make( DummyClass::class ); 61 | $object_b = $injector->make( DummyClass::class ); 62 | 63 | $this->assertNotSame( $object_a, $object_b ); 64 | } 65 | 66 | public function test_it_returns_same_instances_if_shared(): void { 67 | $injector = ( new SimpleInjector() ) 68 | ->share( DummyClass::class ); 69 | $object_a = $injector->make( DummyClass::class ); 70 | $object_b = $injector->make( DummyClass::class ); 71 | 72 | $this->assertSame( $object_a, $object_b ); 73 | } 74 | 75 | public function test_it_can_instantiate_a_class_with_named_arguments(): void { 76 | $object = ( new SimpleInjector() ) 77 | ->make( 78 | DummyClassWithNamedArguments::class, 79 | [ 80 | 'argument_a' => 42, 81 | 'argument_b' => 'Mr Alderson', 82 | ] 83 | ); 84 | 85 | $this->assertInstanceOf( DummyClassWithNamedArguments::class, $object ); 86 | $this->assertEquals( 42, $object->get_argument_a() ); 87 | $this->assertEquals( 'Mr Alderson', $object->get_argument_b() ); 88 | } 89 | 90 | public function test_it_allows_for_skipping_named_arguments_with_default_values(): void { 91 | $object = ( new SimpleInjector() ) 92 | ->make( 93 | DummyClassWithNamedArguments::class, 94 | [ 'argument_a' => 42 ] 95 | ); 96 | 97 | $this->assertInstanceOf( DummyClassWithNamedArguments::class, $object ); 98 | $this->assertEquals( 42, $object->get_argument_a() ); 99 | $this->assertEquals( 'Mr Meeseeks', $object->get_argument_b() ); 100 | } 101 | 102 | public function test_it_throws_if_a_required_named_arguments_is_missing(): void { 103 | $this->expectException( FailedToMakeInstance::class ); 104 | 105 | ( new SimpleInjector() ) 106 | ->make( DummyClassWithNamedArguments::class ); 107 | } 108 | 109 | public function test_it_throws_if_a_circular_reference_is_detected(): void { 110 | $this->expectException( FailedToMakeInstance::class ); 111 | $this->expectExceptionCode( FailedToMakeInstance::CIRCULAR_REFERENCE ); 112 | 113 | ( new SimpleInjector() ) 114 | ->bind( 115 | DummyClass::class, 116 | DummyClassWithDependency::class 117 | ) 118 | ->make( DummyClassWithDependency::class ); 119 | } 120 | 121 | public function test_it_can_delegate_instantiation(): void { 122 | $injector = ( new SimpleInjector() ) 123 | ->delegate( 124 | DummyInterface::class, 125 | function ( string $class_name ): stdClass { 126 | $object = new stdClass(); 127 | $object->class_name = $class_name; 128 | return $object; 129 | } 130 | ); 131 | $object = $injector->make( DummyInterface::class ); 132 | 133 | $this->assertInstanceOf( stdClass::class, $object ); 134 | $this->assertObjectHasProperty( 'class_name', $object ); 135 | $this->assertEquals( DummyInterface::class, $object->class_name ); 136 | } 137 | 138 | public function test_delegation_works_across_resolution(): void { 139 | $injector = ( new SimpleInjector() ) 140 | ->bind( 141 | DummyInterface::class, 142 | DummyClassWithDependency::class 143 | ) 144 | ->delegate( 145 | DummyClassWithDependency::class, 146 | function ( string $class_name ): stdClass { 147 | $object = new stdClass(); 148 | $object->class_name = $class_name; 149 | return $object; 150 | } 151 | ); 152 | $object = $injector->make( DummyInterface::class ); 153 | 154 | $this->assertInstanceOf( stdClass::class, $object ); 155 | $this->assertObjectHasProperty( 'class_name', $object ); 156 | $this->assertEquals( DummyClassWithDependency::class, $object->class_name ); 157 | } 158 | 159 | public function test_arguments_can_be_bound(): void { 160 | $object = ( new SimpleInjector() ) 161 | ->bind_argument( 162 | DummyClassWithNamedArguments::class, 163 | 'argument_a', 164 | 42 165 | ) 166 | ->bind_argument( 167 | SimpleInjector::GLOBAL_ARGUMENTS, // @phpstan-ignore-line 168 | 'argument_b', 169 | 'Mr Alderson' 170 | ) 171 | ->make( DummyClassWithNamedArguments::class ); 172 | 173 | $this->assertInstanceOf( DummyClassWithNamedArguments::class, $object ); 174 | $this->assertEquals( 42, $object->get_argument_a() ); 175 | $this->assertEquals( 'Mr Alderson', $object->get_argument_b() ); 176 | } 177 | 178 | public function test_it_can_use_custom_instantiator(): void { 179 | /** @var MockObject&Instantiator $mock_instantiator */ 180 | $mock_instantiator = $this->createMock( Instantiator::class ); 181 | $mock_instantiator->expects( $this->once() ) 182 | ->method( 'instantiate' ) 183 | ->with( DummyClass::class, [] ) 184 | ->willReturn( new DummyClass() ); 185 | 186 | $injector = new SimpleInjector( $mock_instantiator ); 187 | $instance = $injector->make( DummyClass::class ); 188 | 189 | $this->assertInstanceOf( DummyClass::class, $instance ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/php/Unit/SimpleViewFactoryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf( SimpleViewFactory::class, $factory ); 15 | } 16 | 17 | public function test_it_implements_the_interface(): void { 18 | $factory = new SimpleViewFactory(); 19 | 20 | $this->assertInstanceOf( ViewFactory::class, $factory ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/php/Unit/SimpleViewTest.php: -------------------------------------------------------------------------------- 1 | view_factory = $this->createMock( ViewFactory::class ); 25 | $this->debug_mode = $this->createMock( DebugMode::class ); 26 | } 27 | 28 | public function test_it_can_render_static_view(): void { 29 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/static-view.php', $this->view_factory ); 30 | 31 | $this->assertEquals( '

Rendering works.

', $this->normalize( $view->render() ) ); 32 | } 33 | 34 | public function test_it_can_render_with_context(): void { 35 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/dynamic-view.php', $this->view_factory ); 36 | 37 | $result = $view->render( [ 'some_value' => 'perfectly' ] ); 38 | 39 | $this->assertEquals( 40 | '

Rendering works with context: perfectly.

', 41 | $this->normalize( $result ) 42 | ); 43 | } 44 | 45 | public function test_it_escapes_context_values_by_default(): void { 46 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/dynamic-view.php', $this->view_factory ); 47 | 48 | $result = $view->render( [ 'some_value' => '' ] ); 49 | 50 | $this->assertStringContainsString( 51 | '<script>alert("XSS")</script>', 52 | $result 53 | ); 54 | } 55 | 56 | public function test_it_can_access_raw_context_values(): void { 57 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/dynamic-view.php', $this->view_factory ); 58 | $view->render( [ 'some_value' => 'raw' ] ); 59 | 60 | $this->assertEquals( 'raw', $view->raw( 'some_value' ) ); 61 | } 62 | 63 | public function test_it_can_render_partial_views(): void { 64 | $partial_view = new SimpleView( 'tests/php/Fixture/views/plugin/partial.php', $this->view_factory ); 65 | $this->view_factory->method( 'create' ) 66 | ->willReturn( $partial_view ); 67 | 68 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/view-with-partial.php', $this->view_factory ); 69 | 70 | $result = $view->render( [ 'some_value' => 'nicely' ] ); 71 | 72 | $this->assertStringContainsString( 'nicely', $result ); 73 | } 74 | 75 | public function test_it_throws_on_invalid_path(): void { 76 | $this->expectException( InvalidPath::class ); 77 | 78 | new SimpleView( 'non/existent/path', $this->view_factory ); 79 | } 80 | 81 | public function test_it_throws_on_failed_view_load(): void { 82 | $this->expectException( FailedToLoadView::class ); 83 | 84 | $view = new SimpleView( 'tests/php/Fixture/views/broken-view.php', $this->view_factory ); 85 | $view->render(); 86 | } 87 | 88 | public function test_it_throws_on_invalid_context_property_in_debug_mode(): void { 89 | $this->debug_mode->method( 'is_debug_mode' ) 90 | ->willReturn( true ); 91 | 92 | $this->expectException( InvalidContextProperty::class ); 93 | 94 | $view = new SimpleView( 95 | 'tests/php/Fixture/views/dynamic-view.php', 96 | $this->view_factory, 97 | $this->debug_mode 98 | ); 99 | $view->render( [ 'some_value' => '42' ] ); 100 | 101 | $_ = $view->nonexistent_property; 102 | } 103 | 104 | public function test_it_returns_empty_string_for_missing_property_in_production(): void { 105 | $this->debug_mode->method( 'is_debug_mode' ) 106 | ->willReturn( false ); 107 | 108 | $view = new SimpleView( 109 | 'tests/php/Fixture/views/plugin/dynamic-view.php', 110 | $this->view_factory, 111 | $this->debug_mode 112 | ); 113 | $view->render(); 114 | 115 | $this->assertEquals( '', $view->nonexistent_property ); 116 | } 117 | 118 | public function test_it_returns_null_for_raw_missing_property_in_production(): void { 119 | $this->debug_mode->method( 'is_debug_mode' ) 120 | ->willReturn( false ); 121 | 122 | $view = new SimpleView( 123 | 'tests/php/Fixture/views/plugin/dynamic-view.php', 124 | $this->view_factory, 125 | $this->debug_mode 126 | ); 127 | $view->render(); 128 | 129 | $this->assertNull( $view->raw( 'nonexistent_property' ) ); 130 | } 131 | 132 | public function test_it_handles_non_string_context_values(): void { 133 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/dynamic-view.php', $this->view_factory ); 134 | 135 | $object = new class() { 136 | public function __toString(): string { 137 | return '42'; 138 | } 139 | }; 140 | 141 | $result = $view->render( [ 'some_value' => $object ] ); 142 | 143 | $this->assertStringContainsString( 'Rendering works with context: 42', $result ); 144 | } 145 | 146 | public function test_it_adds_php_extension_if_missing(): void { 147 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/static-view', $this->view_factory ); 148 | 149 | $this->assertEquals( '

Rendering works.

', $this->normalize( $view->render() ) ); 150 | } 151 | 152 | public function test_it_preserves_parent_context_in_partial_views(): void { 153 | $partial_view = new SimpleView( 'tests/php/Fixture/views/plugin/partial.php', $this->view_factory ); 154 | $this->view_factory->method( 'create' ) 155 | ->willReturn( $partial_view ); 156 | 157 | $view = new SimpleView( 'tests/php/Fixture/views/plugin/view-with-partial.php', $this->view_factory ); 158 | 159 | $context = [ 'some_value' => 'shared context' ]; 160 | $result = $view->render( $context ); 161 | 162 | $this->assertStringContainsString( 'shared context', $result ); 163 | } 164 | 165 | /** 166 | * Helper function to normalize output so tests can avoid flaky behavior. 167 | * 168 | * @param string $output The string to normalize. 169 | * @return string The normalized string. 170 | */ 171 | private function normalize( string $output ): string { 172 | // Right now, Patchwork seems to have a bug and injects code in some of the stream wrappers. 173 | // See https://github.com/antecedent/patchwork/issues/151. 174 | // This piece of logic can be removed once the above bug was fixed. 175 | $output = str_replace( ';\Patchwork\CodeManipulation\Stream::reinstateWrapper();', '', $output ); 176 | 177 | return trim( $output ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/php/Unit/TemplatedViewFactoryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf( TemplatedViewFactory::class, $factory ); 15 | } 16 | 17 | public function test_it_implements_the_interface(): void { 18 | $factory = new TemplatedViewFactory(); 19 | 20 | $this->assertInstanceOf( ViewFactory::class, $factory ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/php/Unit/TemplatedViewTest.php: -------------------------------------------------------------------------------- 1 | view_factory_mock = $this->createMock( ViewFactory::class ); 20 | } 21 | 22 | public function test_it_can_be_initialized(): void { 23 | $view = new TemplatedView( 24 | 'static-view', 25 | $this->view_factory_mock, 26 | ViewHelper::LOCATIONS 27 | ); 28 | 29 | $this->assertInstanceOf( TemplatedView::class, $view ); 30 | } 31 | 32 | public function test_it_can_be_rendered(): void { 33 | $view = new TemplatedView( 34 | 'static-view', 35 | $this->view_factory_mock, 36 | ViewHelper::LOCATIONS 37 | ); 38 | 39 | $this->assertStringStartsWith( 40 | '

Rendering works.

', 41 | $view->render() 42 | ); 43 | } 44 | 45 | public function test_it_can_provide_rendering_context(): void { 46 | $view = new TemplatedView( 47 | 'dynamic-view', 48 | $this->view_factory_mock, 49 | ViewHelper::LOCATIONS 50 | ); 51 | 52 | $this->assertStringStartsWith( 53 | '

Rendering works with context: 42.

', 54 | $view->render( [ 'some_value' => 42 ] ) 55 | ); 56 | } 57 | 58 | public function test_it_can_render_partials(): void { 59 | $this->view_factory_mock 60 | ->expects( $this->once() ) 61 | ->method( 'create' ) 62 | ->with( 'partial' ) 63 | ->willReturn( 64 | new TemplatedView( 65 | 'partial', 66 | $this->view_factory_mock, 67 | ViewHelper::LOCATIONS 68 | ) 69 | ); 70 | 71 | $view = new TemplatedView( 72 | 'view-with-partial', 73 | $this->view_factory_mock, 74 | ViewHelper::LOCATIONS 75 | ); 76 | 77 | $this->assertStringStartsWith( 78 | '

Rendering works with partials: 42.

', 79 | $view->render( [ 'some_value' => 42 ] ) 80 | ); 81 | } 82 | 83 | public function test_it_can_be_overridden_in_themes(): void { 84 | $view_a = new TemplatedView( 85 | 'view-a', 86 | $this->view_factory_mock, 87 | ViewHelper::LOCATIONS 88 | ); 89 | $view_b = new TemplatedView( 90 | 'view-b', 91 | $this->view_factory_mock, 92 | ViewHelper::LOCATIONS 93 | ); 94 | $view_c = new TemplatedView( 95 | 'view-c', 96 | $this->view_factory_mock, 97 | ViewHelper::LOCATIONS 98 | ); 99 | 100 | $this->assertStringStartsWith( 101 | '

View A comes from plugin.

', 102 | $view_a->render() 103 | ); 104 | $this->assertStringStartsWith( 105 | '

View B comes from parent theme.

', 106 | $view_b->render() 107 | ); 108 | $this->assertStringStartsWith( 109 | '

View C comes from child theme.

', 110 | $view_c->render() 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/php/Unit/TestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests\Unit; 15 | 16 | use Brain\Monkey; 17 | use Yoast\PHPUnitPolyfills\TestCases\TestCase as PHPUnitTestCase; 18 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; 19 | 20 | abstract class TestCase extends PHPUnitTestCase { 21 | 22 | // Adds Mockery expectations to the PHPUnit assertions count. 23 | use MockeryPHPUnitIntegration; 24 | 25 | protected function set_up() { 26 | parent::set_up(); 27 | Monkey\setUp(); 28 | } 29 | 30 | protected function tear_down() { 31 | Monkey\tearDown(); 32 | parent::tear_down(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/php/ViewHelper.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | * @link https://www.mwpd.io/ 9 | * @copyright 2019 Alain Schlesser 10 | */ 11 | 12 | declare( strict_types=1 ); 13 | 14 | namespace MWPD\BasicScaffold\Tests; 15 | 16 | interface ViewHelper { 17 | 18 | public const VIEWS_FOLDER = 'tests/php/Fixture/views/'; 19 | 20 | public const CHILD_THEME_FOLDER = self::VIEWS_FOLDER . 'child_theme'; 21 | 22 | public const PARENT_THEME_FOLDER = self::VIEWS_FOLDER . 'parent_theme'; 23 | 24 | public const PLUGIN_FOLDER = self::VIEWS_FOLDER . 'plugin'; 25 | 26 | public const LOCATIONS = [ 27 | self::CHILD_THEME_FOLDER, 28 | self::PARENT_THEME_FOLDER, 29 | self::PLUGIN_FOLDER, 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /views/test-backend-service.php: -------------------------------------------------------------------------------- 1 | 7 |
8 |

Hello World! from the plugin ?> plugin!

9 |

Raw value: raw( 'plugin' ) ?>

10 |
11 | -------------------------------------------------------------------------------- /views/test-loop-service.php: -------------------------------------------------------------------------------- 1 | raw( 'post' ); 9 | ?> 10 |
11 | 	Post title: post_title ?>
12 | 	
13 | Post date: post_date ?> 14 |
15 |
--------------------------------------------------------------------------------