Skip to content
Snippets Groups Projects
Verified Commit 9a91616d authored by Bartek Jaskulski's avatar Bartek Jaskulski
Browse files

feat: large rework, needs followup

parent 4914f07a
No related branches found
No related tags found
3 merge requests!3improve into wp-hook and some additional unfinished things,!21.x,!1Draft: Basic implementation of plugin initializer
Showing
with 568 additions and 158 deletions
# WordPress plugin initializer
Bootstrap for your plugins.
Boot your plugin with superpowers.
## Installation
......@@ -12,9 +12,17 @@ composer require wpdesk/wp-init
## Creating a Plugin
A plugin is a simple object created to help bootstrap functionality by allowing you to easily
retrieve plugin information, reference internal files and URLs, and, most importantly, register
hooks.
Preferred method of using this library exercise Object Oriented Programming and organizing your
actions and filters in a multiple classes, although it isn't the only way you can interact (and
benefit from this library).
The plugin initialization consists of the following steps:
1. Create a regular main plugin file, following [header requirements](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/)
1. Prepare DI container definitions for your services.
1. Declare all classes included in hook binding.
The above limits your main plugin file to a short and simple structure.
```php
<?php
......@@ -22,50 +30,43 @@ hooks.
* Plugin Name: Example Plugin
*/
use WPDesk\Init\PluginInit;
use WPDesk\Init;
require __DIR__ . '/vendor/autoload.php';
$plugin = (new PluginInit())->init();
(new Init('config.php'))->boot();
```
`$plugin` is an instance of `Plugin`, which provides a basic API to access information about the plugin.
You can find more elaborate examples for plugin configuration in `tests/Fixtures/` directory.
### Plugin configuration
Although, you don't need additional configuration to begin work with your plugin in OOP manner,
most of the time you will need a few more steps to make the most of plugin initializer.
For plugin configuration, you may focus on declarative configuration.
#### Using dependency injection container
Supported configuration:
Paired with `php-di/php-di`, you are able to take leverage of dependency injection in your
WordPress plugin. `wp-init` is ready to pick up all of your hook provider classes from special
container entry named `hooks`. Any class registered in this array will be called during
`plugins_loaded` hook.
```php
<?php
```shell
composer require php-di/php-di
return [
'services' => 'config/services.inc.php',
'hook_binding' => [],
'cache_path' => 'generated',
'requirements' => [],
'plugin_class_name' => 'Example\Plugin',
];
```
```php
<?php
/**
* Plugin Name: Example Plugin
*/
## Usage with `wpdesk/wp-builder`
use WPDesk\Init\PluginInit;
As a legacy support, it is possible to power up your existing codebase, which uses
`wpdesk/wp-builder` with this library capabilities, as autowired services.
require __DIR__ . '/vendor/autoload.php';
The only change, you have to do (besides configuration of services) is adding _hookables_ as class
string, ready for handling by DI container:
$plugin = (new PluginInit())
->add_container_definitions([
'hooks' => [
\DI\autowire( \Example\PostType\BookPostType::class )
]
])
->init();
```diff
- $this->add_hookable( new \WPDesk\Init\Provider\I18n() );
+ $this->add_hookable( \WPDesk\Init\Provider\I18n::class );
```
#### Target environment requirements
......@@ -94,111 +95,9 @@ $plugin = (new PluginInit())
'wp' => '6.0',
'php' => '7.2'
])
->init();
->boot();
```
## Hook Providers
Related functionality can be encapsulated in a class called a "hook provider" that's registered when bootstrapping the plugin.
Hook providers allow you to encapsulate related functionality, maintain state without using globals, namespace methods without prefixing functions, limit access to internal methods, and make unit testing easier.
For an example, the `WPDesk\Init\Provider\I18n` class is a default hook provider that automatically
loads the text domain so the plugin can be translated.
The only requirement for a hook provider is that it should implement the `HookProvider`
interface by defining a method called `register_hooks()`.
Hook providers are registered with the main plugin instance by calling `Plugin::register_hooks()` like this:
```php
<?php
$plugin->register_hooks(
new \Cedaro\WP\Plugin\Provider\I18n(),
new \Example\PostType\BookPostType()
);
```
The `BookPostType` provider might look something like this:
```php
<?php
namespace Example\PostType;
use WPDesk\Init\Provider\AbstractHookProvider;
class BookPostType extends AbstractHookProvider {
const POST_TYPE = 'book';
public function register_hooks() {
$this->add_action( 'init', 'register_post_type' );
$this->add_action( 'init', 'register_meta' );
}
protected function register_post_type() {
register_post_type( static::POST_TYPE, $this->get_args() );
}
protected function register_meta() {
register_meta( 'post', 'isbn', array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
) );
}
protected function get_args() {
return array(
'hierarchical' => false,
'public' => true,
'rest_base' => 'books',
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => false,
'show_in_rest' => true,
);
}
}
```
<!--
## Protected Hook Callbacks
In WordPress, it's only possible to use public methods of a class as hook callbacks, but in the `BookPostType` hook provider above, the callbacks are protected methods of the class.
Locking down the API like that is possible using the `HooksTrait` [developed by John P. Bloch](https://github.com/johnpbloch/wordpress-dev).
-->
## Plugin Awareness
A hook provider may implement the `PluginAwareInterface` to automatically receive a reference to the plugin when its hooks are registered.
For instance, in this class the `enqueue_assets()` method references the internal `$plugin` property to retrieve the URL to a JavaScript file in the plugin.
```php
<?php
namespace Structure\Provider;
use Cedaro\WP\Plugin\AbstractHookProvider;
class Assets extends AbstractHookProvider {
public function register_hooks() {
$this->add_action( 'wp_enqueue_scripts', 'enqueue_assets' );
}
protected function enqueue_assets() {
wp_enqueue_script(
'structure',
$this->plugin->get_url( 'assets/js/structure.js' )
);
}
}
```
Another example is the `I18n` provider mentioned earlier. It receives a reference to the plugin object so that it can use the plugin's base name and slug to load the text domain.
Classes that extend `AbstractHookProvider` are automatically "plugin aware."
## Credits
This package is heavily inspired by Cedaro's [`wp-plugin`](https://github.com/cedaro/wp-plugin/)
......@@ -207,15 +106,12 @@ and Alain Schlesser's [`basic-scaffold`](https://github.com/mwpd/basic-scaffold)
## Roadmap
1. Add support for path based hook providers discovery similar to Symfony's [controllers resolving](https://github.com/symfony/demo/blob/3787b9f71f6bee24f1ed0718b9a808d824008776/config/routes.yaml#L15-L17)
1. Improve `wpdesk/wp-basic-requirements` library. This is not related directly to this project,
but internals could be rewritten.
1. Improve `wpdesk/wp-basic-requirements` library. This is not related directly to this project, but internals could be rewritten.
1. Scrap plugin data from plugin comment
1. Allow hooks to be called from private and protected methods (in PHP <8.1)
1. Support *bundles* of hook providers. This should be easy to extend plugin capabilities with
shared functions, preserving minimal init system
1. Support *bundles* of hook providers. This should be easy to extend plugin capabilities with shared functions, preserving minimal init system
## License
Copyright (c) 2023 WPDesk
Copyright (c) 2024 WPDesk
This library is licensed under MIT.
......@@ -7,7 +7,7 @@ include $_composer_autoload_path ?? __DIR__ . '/../vendor/autoload.php';
$filename = $argv[1] ?? null;
$parser = new \WPDesk\Init\PluginHeaderParser();
$parser = new \WPDesk\Init\Plugin\DefaultHeaderParser();
$data = $parser->parse( $filename );
......
{
"name": "wpdesk/wp-init",
"description": "Bootstrap for a WordPress plugin",
"minimum-stability": "stable",
"license": "MIT",
"type": "library",
"homepage": "https://gitlab.wpdesk.dev/wpdesk/wp-init",
......@@ -39,17 +38,17 @@
},
"require": {
"php": ">=7.2 | ^8",
"php-di/php-di": "^6 || ^7"
"php-di/php-di": "^6 || ^7",
"psr/container": "~1.0.0 || ^2"
},
"require-dev": {
"wpdesk/wp-basic-requirements": "^3",
"brain/monkey": "^2.6",
"phpunit/phpunit": "^8 || ^9",
"symfony/filesystem": "^6.2",
"brain/monkey": "^2.6",
"phpstan/phpstan": "^1.10",
"szepeviktor/phpstan-wordpress": "^1.3",
"phpstan/extension-installer": "^1.3",
"wpdesk/wp-builder": "^2.0"
"wpdesk/phpstan-rules": "^1.1",
"wpdesk/wp-basic-requirements": "^3",
"wpdesk/wp-builder": "^2.0",
"wpdesk/wp-code-sniffer": "^1.3"
},
"suggest": {
"wpdesk/wp-basic-requirements": "Enables your plugin to check an environment requirement before instantiation, e.g. PHP version or active plugins"
......@@ -62,7 +61,8 @@
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
"phpstan/extension-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding;
interface Binder {
public function can_bind( Definition $def ): bool;
public function bind( Definition $def ): void;
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Binder;
use Psr\Container\ContainerInterface;
use WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition\CallableDefinition;
class CallableBinder implements Binder {
/** @var ContainerInterface */
private $container;
public function __construct( ContainerInterface $c ) {
$this->container = $c;
}
public function can_bind( Definition $def ): bool {
return $def instanceof CallableDefinition;
}
public function bind( Definition $def ): void {
if ( $def instanceof CallableDefinition ) {
$ref = new \ReflectionFunction( $definition->value() );
$parameters = [];
foreach ( $ref->getParameters() as $ref_param ) {
$parameters[] = $container->get( $ref_param->getType()->getName() );
}
$ref->invokeArgs( $parameters );
}
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition\HookableDefinition;
final class CompositeBinder implements Binder {
/** @var Binder[] */
private $binders;
public function __construct( Binder ...$binders ) {
$this->binders = $binders;
}
public function add( Binder $binder ): void {
$this->binders[] = $binder;
}
public function can_bind( Definition $def ): bool {
foreach ( $this->binders as $binder ) {
if ( $binder->can_bind( $def ) ) {
return true;
}
}
return false;
}
public function bind( Definition $def ): void {
foreach ( $this->binders as $binder ) {
if ( $binder->can_bind( $def ) ) {
$binder->bind( $def );
break;
}
}
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Binder;
use Psr\Container\ContainerInterface;
use WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition\HookBinderDefinition;
use WPDesk\Init\Binding\Definition\HookableDefinition;
class HookBinderBinder implements Binder {
/** @var ContainerInterface */
private $container;
public function __construct( ContainerInterface $c ) {
$this->container = $c;
}
public function can_bind( Definition $def ): bool {
return $def instanceof HookBinderDefinition;
}
public function bind( Definition $def ): void {
if ( $this->can_bind( $def ) ) {
$this->container->get( $def->value() )->bind();
}
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Binder;
use Psr\Container\ContainerInterface;
use WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition\HookableDefinition;
class HookableBinder implements Binder {
/** @var ContainerInterface */
private $container;
public function __construct( ContainerInterface $c ) {
$this->container = $c;
}
public function can_bind( Definition $def ): bool {
return $def instanceof HookableDefinition;
}
public function bind( Definition $def ): void {
if ( $def instanceof HookableDefinition ) {
$this->container->get( $def->value() )->hooks();
}
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Binder;
use Psr\Container\ContainerInterface;
use WPDesk\Init\Binding\Binder;
use WPDesk\Init\Binding\StoppableBinder as Stop;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition\HookableDefinition;
class StoppableBinder implements Binder {
/** @var ContainerInterface */
private $container;
/** @var Binder */
private $binder;
private $should_stop = false;
public function __construct( Binder $b, ContainerInterface $c ) {
$this->binder = $b;
$this->container = $c;
}
public function can_bind( Definition $def ): bool {
return $this->binder->can_bind( $def );
}
public function bind( Definition $def ): void {
if ( $this->should_stop === true ) {
return;
}
$this->binder->bind( $def );
if ( $this->can_be_stoppable( $def ) ) {
$binding = $this->container->get( $def->value() );
if ( $binding instanceof Stop && $binding->should_stop() ) {
$this->should_stop = true;
}
}
}
private function can_be_stoppable( Definition $def ): bool {
return is_string( $def->value() ) && class_exists( $def->value() );
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding;
/**
* @template T
*/
interface Definition {
public function hook(): ?string;
/** @return T */
public function value();
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition;
/** @implements Definition<callable> */
class CallableDefinition implements Definition {
/** @var ?string */
private $hook;
/** @var callable */
private $callable;
public function __construct(
callable $callable,
?string $hook = null,
) {
$this->callable = $callable;
$this->hook = $hook;
}
public function hook(): ?string {
return $this->hook;
}
public function value() {
return $this->callable;
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\HookBinder;
/** @implements Definition<class-string<HookBinder>> */
class HookBinderDefinition implements Definition {
/** @var ?string */
private $hook;
/** @var class-string<HookBinder> */
private $hookable;
public function __construct(
string $hookable,
?string $hook = null,
) {
$this->hook = $hook;
$this->hookable = $hookable;
}
public function hook(): ?string {
return $this->hook;
}
public function value() {
return $this->hookable;
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding\Definition;
use WPDesk\Init\Binding\Definition;
/** @implements Definition<class-string<Hookable>> */
class HookableDefinition implements Definition {
/** @var ?string */
private $hook;
/** @var class-string<Hookable> */
private $hookable;
public function __construct(
string $hookable,
?string $hook = null,
) {
$this->hook = $hook;
$this->hookable = $hookable;
}
public function hook(): ?string {
return $this->hook;
}
public function value() {
return $this->hookable;
}
}
<?php
declare(strict_types=1);
namespace WPDesk\Init\Binding;
use WPDesk\Init\Binding\Definition\CallableDefinition;
use WPDesk\Init\Binding\Definition\HookBinderDefinition;
use WPDesk\Init\Binding\Definition\HookableDefinition;
use WPDesk\PluginBuilder\Plugin\Hookable;
class DefinitionFactory {
public function create( $value, ?string $hook ): Definition {
if ( is_string( $value ) && class_exists( $value ) ) {
if ( is_subclass_of( $value, Hookable::class, true ) ) {
return new HookableDefinition( $value, $hook );
}
if ( is_subclass_of( $value, HookBinder::class, true ) ) {
return new HookBinderDefinition( $value, $hook );
}
}
if ( is_callable( $value ) ) {
return new CallableDefinition( $value, $hook );
}
// if ( is_array( $value ) ) {
// return new ArrayDefinition( $value, $hook );
// }
throw new \InvalidArgumentException( 'Unknown definition type' );
}
}
<?php
namespace WPDesk\Init\Binding;
interface HookBinder {
public function bind(): void;
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\Binding\Loader;
use WPDesk\Init\Binding\DefinitionFactory;
use WPDesk\Init\Configuration\ReadableConfig;
use WPDesk\Init\Plugin\Plugin;
class ArrayBindingLoader implements BindingDefinitions {
/** @var array */
private $bindings;
/**
* @var DefinitionFactory
*/
private $factory;
public function __construct( array $bindings, DefinitionFactory $factory ) {
$this->bindings = $bindings;
$this->factory = $factory;
}
public function load(): iterable {
yield from $this->normalize( $this->bindings );
}
private function normalize( $bindings ) {
$normalized = [];
foreach ( $bindings as $key => $value ) {
if ( is_array( $value ) ) {
foreach ( $value as $unit ) {
yield $this->factory->create( $unit, is_int( $key ) ? null : $key );
}
} else {
yield $this->factory->create( $value, is_int( $key ) ? null : $key );
}
}
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\Binding\Loader;
use WPDesk\Init\Binding\Definition;
interface BindingDefinitions {
/**
* @return iterable<Definition>
*/
public function load(): iterable;
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\Binding\Loader;
use WPDesk\Init\Plugin\Plugin;
class CompositeBindingLoader implements BindingDefinitions {
/** @var BindingDefinitionLoader[] */
private $loaders;
public function __construct( BindingDefinitions ...$loaders ) {
$this->loaders = $loaders;
}
public function load(): iterable {
foreach ( $this->loaders as $loader ) {
yield from $loader->load();
}
}
public function add( BindingDefinitions $loader ): void {
$this->loaders[] = $loader;
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\Binding\Loader;
use WPDesk\Init\Configuration\ReadableConfig;
use WPDesk\Init\Loader\PhpFileLoader;
use WPDesk\Init\Util\Path;
class ConfigurationBindingLoader extends DirectoryBasedLoader {
public function __construct(
ReadableConfig $config,
string $plugin_path,
PhpFileLoader $loader,
DefinitionFactory $def_factory
) {
parent::__construct(
( new Path( $config->get( 'hook_resources_path' ) ) )->absolute( $plugin_path ),
$loader,
$def_factory
);
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\Binding\Loader;
use WPDesk\Init\Binding\DefinitionFactory;
use WPDesk\Init\Plugin\Plugin;
use WPDesk\Init\Util\Path;
use WPDesk\Init\Configuration\ReadableConfig;
use WPDesk\Init\Loader\PhpFileLoader;
class DirectoryBasedLoader implements BindingDefinitions {
/** @var Path */
private $path;
/** @var PhpFileLoader */
private $loader;
/** @var DefinitionFactory */
private $def_factory;
public function __construct( $path, PhpFileLoader $loader, DefinitionFactory $def_factory ) {
$this->path = new Path( (string) $path );
$this->loader = $loader;
$this->def_factory = $def_factory;
}
public function load(): iterable {
if ( $this->path->is_directory() ) {
foreach ( $this->path->read_directory() as $filename ) {
yield from $this->load_from_file( $filename );
}
} else {
yield from $this->load_from_file($this->path);
}
}
private function load_from_file( Path $filename ) {
if ( ! $filename->is_file() ) {
return;
}
$hooks = $this->loader->load( (string) $filename );
if ( $filename->get_basename() !== 'index.php' ) {
$hooks = [ $filename->get_filename_without_extension() => $hooks ];
}
yield from (new ArrayBindingLoader( $hooks, $this->def_factory ))->load();
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment