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

Basic implementation of plugin initializer

parents
No related branches found
No related tags found
2 merge requests!21.x,!1Draft: Basic implementation of plugin initializer
Pipeline #165546 failed
This commit is part of merge request !2. Comments created here will be created in the context of that merge request.
Showing
with 933 additions and 0 deletions
.gitignore 0 → 100644
vendor
composer.lock
README.md 0 → 100644
+ 201
0
View file @ e7302553
# WordPress plugin initializer
Bootstrap for your plugins.
## Installation
To use this library in your project, add it to `composer.json`:
```sh
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.
```php
<?php
/**
* Plugin Name: Example Plugin
*/
use WPDesk\Init\PluginInit;
require __DIR__ . '/vendor/autoload.php';
$plugin = (new PluginInit())->init();
```
`$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.
#### Using dependency injection container
Paired with `php-di/php-di`, you are able to take leverage of dependency injection in your
WordPress plugin.
```php
<?php
/**
* Plugin Name: Example Plugin
*/
use WPDesk\Init\PluginInit;
require __DIR__ . '/vendor/autoload.php';
$plugin = (new PluginInit())
->add_container_definitions(__DIR__ . '/config/services.inc.php')
->init();
```
#### Target environment requirements
`wp-init` also integrates with `wpdesk/wp-basic-requirements` to validate target environment and
detect, whether your plugin can actually instantiate. With this addition, if your plugin fails
to positively validate environment, your hooks are not triggered, and the website admin is
provided with actionable message.
```shell
composer require wpdesk/wp-basic-requirements
```
```php
<?php
/**
* Plugin Name: Example Plugin
*/
use WPDesk\Init\PluginInit;
require __DIR__ . '/vendor/autoload.php';
$plugin = (new PluginInit())
->set_requirements([
'wp' => '6.0',
'php' => '7.2'
])
->init();
```
## 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/)
and Alain Schlesser's [`basic-scaffold`](https://github.com/mwpd/basic-scaffold).
## License
Copyright (c) 2023 WPDesk
This library is licensed under MIT.
composer.json 0 → 100644
{
"name": "wpdesk/wp-init",
"description": "Bootstrap for a WordPress plugin",
"minimum-stability": "stable",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "Bartek Jaskulski",
"email": "bjaskulski@protonmail.com"
}
],
"autoload": {
"psr-4": {
"WPDesk\\Init\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"WPDesk\\Init\\Tests\\": "tests"
},
"classmap": [
"vendor/wpdesk/wp-basic-requirements"
]
},
"require": {
"php": ">=7.2 | ^8",
"psr/container": "^1 || ^2"
},
"require-dev": {
"wpdesk/wp-basic-requirements": "^3",
"php-di/php-di": "^6 || ^7",
"phpunit/phpunit": "^8 || ^9",
"symfony/filesystem": "^6.2",
"brain/monkey": "^2.6"
},
"suggest": {
"wpdesk/wp-basic-requirements": "Enables your plugin to check an environment requirement before instantiation, e.g. PHP version or active plugins",
"php-di/php-di": "Allows a plugin to seamlessly integrate with a dependency injection container"
},
"conflict": {
"wpdesk/wp-basic-requirements": "<3, >=4",
"php-di/php-di": "<6, >=8"
},
"scripts": {
"test": "vendor/bin/phpunit --bootstrap tests/bootstrap.php ./tests"
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
/**
* Something that can be instantiated conditionally.
*
* In hook provider context a class marked as being conditional can be asked whether its
* hooks should be fired and integrated into WordPress system. An example would be a service that is
* only available on the admin backend.
*
* This allows for a more systematic and automated optimization of how the
* different parts of the plugin are enabled or disabled.
*
* @author Alain Schlesser <alain.schlesser@gmail.com>
*/
interface Conditional {
/**
* Check whether the conditional object is currently needed.
*
* @return bool Whether the conditional object is needed.
*/
public function is_needed(): bool;
}
<?php
declare(strict_types=1);
namespace WPDesk\Init;
use Psr\Container\ContainerInterface;
interface ContainerAwareInterface {
public function set_container( ContainerInterface $container ): void;
}
<?php
declare(strict_types=1);
namespace WPDesk\Init;
use Psr\Container\ContainerInterface;
trait ContainerAwareTrait {
/** @var ContainerInterface */
private $container;
public function set_container( ContainerInterface $container ): void {
$this->container = $container;
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
use WPDesk\Init\HooksProvider;
use WPDesk\Init\PluginAwareInterface;
use WPDesk\Init\PluginAwareTrait;
abstract class AbstractHookProvider implements HooksProvider, PluginAwareInterface {
use PluginAwareTrait;
}
\ No newline at end of file
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
use WPDesk\Init\HooksProvider;
use WPDesk\Init\PluginAwareInterface;
use WPDesk\Init\PluginAwareTrait;
class ActivationDate implements HooksProvider, PluginAwareInterface {
use PluginAwareTrait;
public function register_hooks(): void {
add_action(
'activated_plugin',
function ( $plugin_file, $network_wide = false ) {
if ( ! $network_wide && $this->plugin->get_basename() === $plugin_file ) {
$option_name = 'plugin_activation_' . $plugin_file;
$activation_date = get_option( $option_name, '' );
if ( '' === $activation_date ) {
$activation_date = current_time( 'mysql' );
update_option( $option_name, $activation_date );
}
}
}
);
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
abstract class ActivationHook extends AbstractHookProvider {
public function register_hooks(): void {
register_activation_hook(
$this->plugin->get_basename(),
[$this, 'activate']
);
}
abstract public function activate(): void;
}
\ No newline at end of file
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
use WPDesk\Init\ContainerAwareInterface;
use WPDesk\Init\ContainerAwareTrait;
use WPDesk\Init\HooksProvider;
use WPDesk\Init\PluginAwareInterface;
use WPDesk\Init\PluginAwareTrait;
class ContainerHookProvider implements HooksProvider, PluginAwareInterface, ContainerAwareInterface {
use PluginAwareTrait;
use ContainerAwareTrait;
public function register_hooks(): void {
add_action(
'plugins_loaded',
function () {
if ( $this->container->has( 'hooks' ) ) {
foreach ( $this->container->get( 'hooks' ) as $hook_provider ) {
$this->plugin->register_hooks( $hook_provider );
}
}
}
);
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
abstract class DeactivationHook extends AbstractHookProvider {
public function register_hooks(): void {
register_deactivation_hook(
$this->plugin->get_basename(),
[$this, 'deactivate']
);
}
abstract public function deactivate(): void;
}
\ No newline at end of file
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
use WPDesk\Init\HooksProvider;
use WPDesk\Init\PluginAwareInterface;
use WPDesk\Init\PluginAwareTrait;
class I18n implements HooksProvider, PluginAwareInterface {
use PluginAwareTrait;
public function register_hooks(): void {
if ( did_action( 'plugins_loaded' ) ) {
$this->load_textdomain();
} else {
add_action( 'plugins_loaded', [ $this, 'load_textdomain' ] );
}
}
public function load_textdomain(): void {
$plugin_rel_path = dirname( $this->plugin->get_basename() ) . '/lang';
\load_plugin_textdomain( $this->plugin->get_slug(), false, $plugin_rel_path );
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init\HookProvider;
use WPDesk\Init\HooksProvider;
use WPDesk\Init\PluginAwareInterface;
use WPDesk\Init\PluginAwareTrait;
class WooCommerceHPOSCompatibility implements HooksProvider, PluginAwareInterface {
use PluginAwareTrait;
public function register_hooks(): void {
add_action(
'before_woocommerce_init',
function () {
$features_util_class = '\\' . 'Automattic' . '\\' . 'WooCommerce' . '\\' . 'Utilities' . '\\' . 'FeaturesUtil';
if ( class_exists( $features_util_class ) ) {
$features_util_class::declare_compatibility( 'custom_order_tables', $this->plugin->get_basename(), true );
}
}
);
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
/**
* Hook is a special kind of service which lives only to integrate with WordPress lifecycle
* system. By design, hook providers should be lightweight classes which focus its main logic on
* integration with actions and filters.
*
* @author Brady Vercher
*/
interface HooksProvider {
/**
* Register hooks for the plugin.
*/
public function register_hooks(): void;
}
\ No newline at end of file
src/Plugin.php 0 → 100644
+ 219
0
View file @ e7302553
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
final class Plugin implements ContainerAwareInterface {
use ContainerAwareTrait;
/**
* Plugin basename.
*
* Ex: plugin-name/plugin-name.php
*
* @var string
*/
protected $basename;
/**
* Absolute path to the main plugin directory.
*
* @var string
*/
protected $directory;
/**
* Plugin name to display.
*
* @var string
*/
private $name;
/**
* Absolute path to the main plugin file.
*
* @var string
*/
protected $file;
/**
* Plugin identifier.
*
* @var string
*/
protected $slug;
/**
* URL to the main plugin directory.
*
* @var string
*/
protected $url;
/**
* Retrieve the absolute path for the main plugin file.
*
* @return string
*/
public function get_basename(): string {
return $this->basename;
}
/**
* Set the plugin basename.
*
* @param string $basename Relative path from the main plugin directory.
*
* @return $this
*/
public function set_basename( string $basename ): self {
$this->basename = $basename;
return $this;
}
/**
* Retrieve the plugin directory.
*
* @return string
*/
public function get_directory(): string {
return $this->directory;
}
/**
* Set the plugin's directory.
*
* @param string $directory Absolute path to the main plugin directory.
*
* @return $this
*/
public function set_directory( string $directory ): self {
$this->directory = rtrim( $directory, '/' ) . '/';
return $this;
}
/**
* Retrieve the path to a file in the plugin.
*
* @param string $path Optional. Path relative to the plugin root.
*
* @return string
*/
public function get_path( string $path = '' ): string {
return $this->directory . ltrim( $path, '/' );
}
/**
* Retrieve the absolute path for the main plugin file.
*
* @return string
*/
public function get_file(): string {
return $this->file;
}
/**
* Set the path to the main plugin file.
*
* @param string $file Absolute path to the main plugin file.
*
* @return $this
*/
public function set_file( string $file ): self {
$this->file = $file;
return $this;
}
/**
* @param string $name
*
* @return self
*/
public function set_name( string $name ): self {
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function get_name(): string {
return $this->name ?? $this->get_slug();
}
/**
* Retrieve the plugin identifier.
*
* @return string
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Set the plugin identifier.
*
* @param string $slug Plugin identifier.
*
* @return $this
*/
public function set_slug( string $slug ) {
$this->slug = $slug;
return $this;
}
/**
* Retrieve the URL for a file in the plugin.
*
* @param string $path Optional. Path relative to the plugin root.
*
* @return string
*/
public function get_url( string $path = '' ) {
return $this->url . ltrim( $path, '/' );
}
/**
* Set the URL for plugin directory root.
*
* @param string $url URL to the root of the plugin directory.
*
* @return $this
*/
public function set_url( string $url ) {
$this->url = rtrim( $url, '/' ) . '/';
return $this;
}
/**
* Register hooks for the plugin.
*
* @param HooksProvider ...$providers
*
* @return void
*/
public function register_hooks( HooksProvider ...$providers ): void {
foreach ( $providers as $provider ) {
if ( $provider instanceof PluginAwareInterface ) {
$provider->set_plugin( $this );
}
if ( $provider instanceof ContainerAwareInterface ) {
$provider->set_container( $this->container );
}
if ( $provider instanceof Conditional && ! $provider->is_needed() ) {
continue;
}
$provider->register_hooks();
}
}
}
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
/**
* Plugin aware interface.
*
* @author Brady Vercher
*/
interface PluginAwareInterface {
/**
* Set the main plugin instance.
*/
public function set_plugin( Plugin $plugin ): void;
}
\ No newline at end of file
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
/**
* @author Brady Vercher
*/
trait PluginAwareTrait {
/** @var Plugin */
protected $plugin;
/**
* Set the main plugin instance.
*/
public function set_plugin( Plugin $plugin ): void {
$this->plugin = $plugin;
}
}
\ No newline at end of file
<?php
declare( strict_types=1 );
namespace WPDesk\Init;
use DI\ContainerBuilder;
use DI\Definition\Source\DefinitionSource;
use Psr\Container\ContainerInterface;
/**
* Plugin builder class responsible for our initialization system.
*/
final class PluginInit {
/** @var array */
private $requirements = [];
/** @var array Dependency injection container definitions */
private $definitions = [];
/** @var string|null Plugin filename. */
private $filename;
/** @var string|null Plugin identifier. */
private $slug;
public function set_requirements( array $requirements ): self {
$this->requirements = $requirements;
return $this;
}
/**
* Define plugin slug. This value will be used as main plugin identifier and as translation
* text domain. By default, plugin slug is set to plugin directory name.
*/
public function set_slug( string $slug ): self {
$this->slug = $slug;
return $this;
}
/**
* Explicitly set name of main plugin file used for reference. This value should be set with
* caution, as filename is the most important identifier, which allows us to retreive further
* plugin data, such as basename, plugin dir and url. By default, this value is set to
* original caller's file name.
*/
public function set_filename( string $filename ): self {
$this->filename = $filename;
return $this;
}
/**
* Add definition sources to use in dependency injection container.
* If plugin doesn't receive any definitions, then container build is skipped.
*
* @see https://php-di.org/doc/container-configuration.html
*
* @param array|DefinitionSource|string $definitions
*/
public function add_container_definitions( ...$definitions ): self {
$this->definitions = array_merge( $this->definitions, $definitions );
return $this;
}
/**
* Build and return a plugin.
*
* @return Plugin|null If plugin failed to build (e.g. requirements are not fulfilled),
* initialization process returns null. There are no exceptions thrown on foreseeable issues
* as those cases should be handled gracefully, by displaying admin notice if possible and
* preventing to initialize plugin functions without disrupting a website.
*/
public function init(): ?Plugin {
if ( empty( $this->filename ) ) {
$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
$this->filename = $backtrace[0]['file'];
}
$plugin = $this->create_plugin();
if (
! empty( $this->requirements ) &&
class_exists( \WPDesk_Basic_Requirement_Checker_Factory::class ) &&
! $this->requirements_met( $plugin )
) {
return null;
}
if ( ! empty( $this->definitions ) && class_exists( ContainerBuilder::class ) ) {
$plugin->set_container( $this->build_container( $plugin ) );
}
return $this->register_default_providers( $plugin );
}
private function build_container( Plugin $plugin ): ContainerInterface {
$builder = new ContainerBuilder();
if ( ! getenv( 'WPDESK_DEVELOPMENT' ) ) {
$builder->enableCompilation(
$plugin->get_path( '/cache' ),
str_replace( '-', '_', $plugin->get_slug() ) . '_container'
);
}
$builder->addDefinitions( ...$this->definitions );
return $builder->build();
}
private function register_default_providers( Plugin $plugin ): Plugin {
$plugin->register_hooks(
new HookProvider\I18n(),
new HookProvider\WooCommerceHPOSCompatibility(),
new HookProvider\ActivationDate()
);
if ( ! empty( $this->definitions ) ) {
$plugin->register_hooks( new HookProvider\ContainerHookProvider() );
}
return $plugin;
}
private function create_plugin(): Plugin {
$plugin = new Plugin();
$plugin->set_file( $this->filename )
->set_basename( plugin_basename( $this->filename ) )
->set_directory( plugin_dir_path( $this->filename ) )
->set_url( plugin_dir_url( $this->filename ) )
->set_slug( $this->slug ?? basename( $plugin->get_directory() ) );
return $plugin;
}
/**
* @param Plugin $plugin
*
* @return bool
*/
private function requirements_met( Plugin $plugin ): bool {
$requirements_factory = new \WPDesk_Basic_Requirement_Checker_Factory();
$requirements = $requirements_factory->create_from_requirement_array(
$plugin->get_basename(),
$plugin->get_name(),
$this->requirements,
$plugin->get_slug()
);
if ( ! $requirements->are_requirements_met() ) {
$requirements->render_notices();
return false;
}
return true;
}
}
<?php
$plugin = ( new \WPDesk\Init\PluginInit() )
->set_requirements( [
'wp' => '5.6',
'php' => '7.2'
] )
->add_container_definitions( [
'hello' => 'world',
'hooks' => [
'anonymous' => static function () {
return new class implements \WPDesk\Init\HooksProvider {
public function register_hooks(): void {
// TODO: Implement register_hooks() method.
}
};
}
],
] )
->init();
<?php
$plugin = ( new \WPDesk\Init\PluginInit() )->init();
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment