diff --git a/composer.json b/composer.json index a7bf0c0..0cdb7ca 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,6 @@ "require": { "php": "^8.4", "brightnucleus/config": "^0.5", - "cedaro/wp-plugin": "^1.0", "composer/installers": "^2" }, "require-dev": { diff --git a/config/defaults.php b/config/defaults.php index b6f9e21..89bbd26 100644 --- a/config/defaults.php +++ b/config/defaults.php @@ -2,9 +2,14 @@ /** * Plugin configuration file * + * Translatable labels are closures, so the translation functions only run + * when the labels are resolved during hook callbacks (after init). Calling + * translation functions while this file loads would trigger the + * _load_textdomain_just_in_time notice in WordPress 6.7+. + * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -12,65 +17,34 @@ namespace Gamajo\PluginSlug; -$plugin_slug_plugin = [ - 'textdomain' => 'plugin-slug', - 'languages_dir' => 'languages', -]; - $plugin_slug_settings = [ 'submenu_pages' => [ [ - 'parent_slug' => 'options-general.php', - 'page_title' => __( 'Plugin Slug Settings', 'plugin-slug' ), - 'menu_title' => __( 'Plugin Slug', 'plugin-slug' ), - 'capability' => 'manage_options', - 'menu_slug' => 'plugin-slug', - 'view' => PLUGIN_SLUG_DIR . 'views/admin-page.php', - 'dependencies' => [ - 'styles' => [], - 'scripts' => [ - [ - 'handle' => 'plugin-slug-js', - 'src' => PLUGIN_SLUG_URL . 'js/admin-page.js', - 'deps' => [ 'jquery' ], - 'ver' => '1.2.3', - 'in_footer' => true, - 'is_needed' => function ( $context ): bool { - if ( $context ) { - return false; - } - - return true; - }, - 'localize' => [ - 'name' => 'pluginSlugI18n', - 'data' => function ( $context ): array { - return [ - 'test_localize_data' => 'test_localize_value', - 'context' => $context, - ]; - }, - ], - ], - ], - 'handlers' => [ - 'scripts' => 'BrightNucleus\Dependency\ScriptHandler', - 'styles' => 'BrightNucleus\Dependency\StyleHandler', - ], - ], + 'parent_slug' => 'options-general.php', + 'page_title' => static fn(): string => __( 'Plugin Slug Settings', 'plugin-slug' ), + 'menu_title' => static fn(): string => __( 'Plugin Slug', 'plugin-slug' ), + 'capability' => 'manage_options', + 'menu_slug' => 'plugin-slug', + 'view' => PLUGIN_SLUG_DIR . 'views/admin-page.php', ], ], 'settings' => [ 'setting1' => [ 'option_group' => 'pluginslug', - 'sanitize_callback' => null, + 'sanitize_callback' => static function ( mixed $value ): array { + if ( ! is_array( $value ) ) { + return []; + } + + return array_map( 'sanitize_text_field', $value ); + }, 'sections' => [ 'section1' => [ - 'title' => __( 'My Section Title', 'plugin-slug' ), + 'title' => static fn(): string => __( 'My Section Title', 'plugin-slug' ), 'view' => PLUGIN_SLUG_DIR . 'views/section1.php', 'fields' => [ 'field1' => [ - 'title' => __( 'My Field Title', 'plugin-slug' ), + 'title' => static fn(): string => __( 'My Field Title', 'plugin-slug' ), 'view' => PLUGIN_SLUG_DIR . 'views/field1.php', ], ], @@ -83,7 +57,6 @@ return [ 'Gamajo' => [ 'PluginSlug' => [ - 'Plugin' => $plugin_slug_plugin, 'Settings' => $plugin_slug_settings, ], ], diff --git a/plugin-slug.php b/plugin-slug.php index 1758cd6..b9202c7 100644 --- a/plugin-slug.php +++ b/plugin-slug.php @@ -2,24 +2,27 @@ /** * Plugin Name * + * The GitHub Plugin URI header below is read by Git Updater (formerly + * GitHub Updater) to serve plugin updates directly from GitHub. + * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later * * @wordpress-plugin * Plugin Name: Plugin Boilerplate - * Plugin URI: https://github.com/garyjones/... + * Plugin URI: https://github.com/GaryJones/plugin-slug * Description: ... * Version: 0.1.0 * Author: Gary Jones * Author URI: https://garyjones.io * Text Domain: plugin-slug * License: GPL-2.0-or-later - * License URI: http://www.gnu.org/licenses/gpl-2.0.txt - * GitHub Plugin URI: https://github.com/garyjones/... + * License URI: https://www.gnu.org/licenses/gpl-2.0.html + * GitHub Plugin URI: https://github.com/GaryJones/plugin-slug * Requires PHP: 8.4 - * Requires WP: 6.9 + * Requires at least: 6.9 */ declare( strict_types = 1 ); @@ -37,15 +40,37 @@ define( 'PLUGIN_SLUG_DIR', plugin_dir_path( __FILE__ ) ); } -if ( ! defined( 'PLUGIN_SLUG_URL' ) ) { - define( 'PLUGIN_SLUG_URL', plugin_dir_url( __FILE__ ) ); -} - // Load Composer autoloader. if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { require_once __DIR__ . '/vendor/autoload.php'; } -// Initialize the plugin. -$GLOBALS['plugin_slug'] = new Plugin( ConfigFactory::create( __DIR__ . '/config/defaults.php' )->getSubConfig( 'Gamajo\PluginSlug' ) ); -$GLOBALS['plugin_slug']->run(); +// Initialize the plugin on a hook, rather than at file include time. +add_action( + 'plugins_loaded', + static function (): void { + plugin_slug()->run(); + } +); + +/** + * Get the plugin instance. + * + * Builds and caches the Plugin object on first call, so no work happens + * when this file is merely included. + * + * @since 0.1.0 + * + * @return Plugin Plugin instance. + */ +function plugin_slug(): Plugin { + static $plugin = null; + + if ( null === $plugin ) { + $plugin = new Plugin( + ConfigFactory::create( __DIR__ . '/config/defaults.php' )->getSubConfig( 'Gamajo\PluginSlug' ) + ); + } + + return $plugin; +} diff --git a/src/Foo.php b/src/Foo.php index 162f6a1..2cc8a9e 100644 --- a/src/Foo.php +++ b/src/Foo.php @@ -4,7 +4,7 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -14,10 +14,16 @@ /** * Foo class. + * + * @since 0.1.0 */ class Foo { /** * Bar. + * + * @since 0.1.0 + * + * @return string Identification of the method being called. */ public function bar(): string { return 'Foo::bar()'; @@ -26,6 +32,8 @@ public function bar(): string { /** * Returns true, always. * + * @since 0.1.0 + * * @return true */ public function is_true(): bool { diff --git a/src/Plugin.php b/src/Plugin.php index 35070cd..3ce59c6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,7 +4,7 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -15,7 +15,6 @@ use BrightNucleus\Config\ConfigInterface; use BrightNucleus\Config\ConfigTrait; use BrightNucleus\Config\Exception\FailedToProcessConfigException; -use BrightNucleus\Settings\Settings; /** * Main plugin class. @@ -29,19 +28,9 @@ class Plugin { use ConfigTrait; - /** - * Static instance of the plugin. - * - * @since 0.1.0 - */ - protected static Plugin $instance; - /** * Instantiate a Plugin object. * - * Don't call the constructor directly, use the `Plugin::get_instance()` - * static method instead. - * * @since 0.1.0 * * @throws FailedToProcessConfigException If the Config could not be parsed correctly. @@ -58,5 +47,80 @@ public function __construct( ConfigInterface $config ) { * @since 0.1.0 */ public function run(): void { + add_action( 'admin_menu', $this->register_admin_pages( ... ) ); + add_action( 'admin_init', $this->register_settings( ... ) ); + } + + /** + * Register the plugin admin pages from the config. + * + * Runs on the admin_menu hook, which fires after init, so the page and + * menu title closures can be safely resolved to translated strings here. + * + * @since 0.1.0 + */ + public function register_admin_pages(): void { + foreach ( $this->getConfigKey( 'Settings', 'submenu_pages' ) as $page ) { + add_submenu_page( + $page['parent_slug'], + $page['page_title'](), + $page['menu_title'](), + $page['capability'], + $page['menu_slug'], + static function () use ( $page ): void { + require $page['view']; + } + ); + } + } + + /** + * Register settings, sections and fields from the config. + * + * Each field passes a label_for argument that matches the id of the + * input in the field view, so the rendered field title becomes a label + * for the form control. + * + * @since 0.1.0 + */ + public function register_settings(): void { + foreach ( $this->getConfigKey( 'Settings', 'settings' ) as $option_name => $setting ) { + register_setting( + $setting['option_group'], + $option_name, + [ + 'sanitize_callback' => $setting['sanitize_callback'], + ] + ); + + foreach ( $setting['sections'] as $section_id => $section ) { + add_settings_section( + $section_id, + $section['title'](), + static function () use ( $section ): void { + require $section['view']; + }, + $setting['option_group'] + ); + + foreach ( $section['fields'] as $field_id => $field ) { + add_settings_field( + $field_id, + $field['title'](), + // $args is used by the view file pulled in via require. + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + static function ( array $args ) use ( $field ): void { + require $field['view']; + }, + $setting['option_group'], + $section_id, + [ + 'label_for' => $field_id, + 'option_name' => $option_name, + ] + ); + } + } + } } } diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php new file mode 100644 index 0000000..c30f55a --- /dev/null +++ b/tests/Unit/PluginTest.php @@ -0,0 +1,406 @@ +fixtures = dirname( __DIR__ ) . '/fixtures/'; + + // The field fixture mirrors the real view and escapes its output. + Functions\when( 'esc_html' )->returnArg(); + } + + /** + * Build the test Config consumed by the Plugin. + * + * The shape mirrors config/defaults.php but stays minimal: one submenu page + * and one setting that contains one section with one field. Translatable + * labels are closures and 'view' values point at the fixtures. + * + * @return Config Config to parametrize the Plugin. + */ + private function make_config(): Config { + return new Config( + [ + 'Settings' => [ + 'submenu_pages' => [ + [ + 'parent_slug' => 'options-general.php', + 'page_title' => static fn(): string => 'Page Title', + 'menu_title' => static fn(): string => 'Menu Title', + 'capability' => 'manage_options', + 'menu_slug' => 'plugin-slug', + 'view' => $this->fixtures . 'page.php', + ], + ], + 'settings' => [ + 'option1' => [ + 'option_group' => 'pluginslug', + 'sanitize_callback' => static fn( $value ): string => 'sanitized:' . $value, + 'sections' => [ + 'section1' => [ + 'title' => static fn(): string => 'Section Title', + 'view' => $this->fixtures . 'section.php', + 'fields' => [ + 'field1' => [ + 'title' => static fn(): string => 'Field Title', + 'view' => $this->fixtures . 'field.php', + ], + ], + ], + ], + ], + ], + ], + ] + ); + } + + /** + * Capture the output produced by invoking a render closure. + * + * @param Closure $closure Render closure captured from a registration call. + * @param array ...$args Arguments to pass to the closure. + * + * @return string Buffered output. + */ + private function capture( Closure $closure, array ...$args ): string { + ob_start(); + $closure( ...$args ); + + return (string) ob_get_clean(); + } + + /** + * The run() method wires register_admin_pages() to the admin_menu hook. + */ + public function test_run_hooks_register_admin_pages_onto_admin_menu(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'add_action' ) + ->once() + ->with( + 'admin_menu', + Mockery::on( + static function ( $candidate ) use ( $plugin ): bool { + if ( ! $candidate instanceof Closure ) { + return false; + } + + $reflection = new ReflectionFunction( $candidate ); + + return 'register_admin_pages' === $reflection->getName() + && $plugin === $reflection->getClosureThis(); + } + ) + ); + + // The admin_init hook is also added, but is asserted by another test. + Functions\expect( 'add_action' )->with( 'admin_init', Mockery::any() ); + + $plugin->run(); + } + + /** + * The run() method wires register_settings() to the admin_init hook. + */ + public function test_run_hooks_register_settings_onto_admin_init(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'add_action' )->with( 'admin_menu', Mockery::any() ); + + Functions\expect( 'add_action' ) + ->once() + ->with( + 'admin_init', + Mockery::on( + static function ( $candidate ) use ( $plugin ): bool { + if ( ! $candidate instanceof Closure ) { + return false; + } + + $reflection = new ReflectionFunction( $candidate ); + + return 'register_settings' === $reflection->getName() + && $plugin === $reflection->getClosureThis(); + } + ) + ); + + $plugin->run(); + } + + /** + * Each submenu page is registered with exact arguments. + */ + public function test_register_admin_pages_passes_exact_arguments(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'add_submenu_page' ) + ->once() + ->with( + 'options-general.php', + 'Page Title', + 'Menu Title', + 'manage_options', + 'plugin-slug', + Mockery::type( Closure::class ) + ); + + $plugin->register_admin_pages(); + } + + /** + * The page render closure requires the configured view file. + */ + public function test_register_admin_pages_render_closure_requires_view(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'add_submenu_page' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ) + ); + + $plugin->register_admin_pages(); + + static::assertInstanceOf( Closure::class, $captured ); + static::assertSame( 'PAGE_FIXTURE_OUTPUT', $this->capture( $captured ) ); + } + + /** + * The setting is registered with exact arguments. + */ + public function test_register_settings_registers_setting_with_exact_arguments(): void { + $config = $this->make_config(); + $plugin = new Testee( $config ); + + $sanitize = $config->getKey( [ 'Settings', 'settings', 'option1', 'sanitize_callback' ] ); + + Functions\expect( 'register_setting' ) + ->once() + ->with( + 'pluginslug', + 'option1', + Mockery::on( + static fn( $args ): bool => is_array( $args ) + && array_keys( $args ) === [ 'sanitize_callback' ] + && $args['sanitize_callback'] === $sanitize + ) + ); + + Functions\expect( 'add_settings_section' )->andReturnNull(); + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + } + + /** + * The sanitize callback wired into the setting is the one from the Config. + */ + public function test_register_settings_uses_config_sanitize_callback(): void { + $config = $this->make_config(); + $plugin = new Testee( $config ); + + $captured = null; + + Functions\expect( 'register_setting' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( array $args ) use ( &$captured ): bool { + $captured = $args['sanitize_callback'] ?? null; + + return true; + } + ) + ); + + Functions\expect( 'add_settings_section' )->andReturnNull(); + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + + static::assertInstanceOf( Closure::class, $captured ); + static::assertSame( 'sanitized:raw', $captured( 'raw' ) ); + } + + /** + * Each section is registered with exact arguments. + */ + public function test_register_settings_registers_section_with_exact_arguments(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'register_setting' )->andReturnNull(); + + Functions\expect( 'add_settings_section' ) + ->once() + ->with( + 'section1', + 'Section Title', + Mockery::type( Closure::class ), + 'pluginslug' + ); + + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + } + + /** + * The section render closure requires the configured view file. + */ + public function test_register_settings_section_closure_requires_view(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'register_setting' )->andReturnNull(); + + Functions\expect( 'add_settings_section' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ), + Mockery::any() + ); + + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + + static::assertInstanceOf( Closure::class, $captured ); + static::assertSame( 'SECTION_FIXTURE_OUTPUT', $this->capture( $captured ) ); + } + + /** + * Each field is registered with exact arguments. + */ + public function test_register_settings_registers_field_with_exact_arguments(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'register_setting' )->andReturnNull(); + Functions\expect( 'add_settings_section' )->andReturnNull(); + + Functions\expect( 'add_settings_field' ) + ->once() + ->with( + 'field1', + 'Field Title', + Mockery::type( Closure::class ), + 'pluginslug', + 'section1', + [ + 'label_for' => 'field1', + 'option_name' => 'option1', + ] + ); + + $plugin->register_settings(); + } + + /** + * The field render closure requires the view and receives the $args array. + */ + public function test_register_settings_field_closure_requires_view_with_args(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'register_setting' )->andReturnNull(); + Functions\expect( 'add_settings_section' )->andReturnNull(); + + Functions\expect( 'add_settings_field' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ), + Mockery::any(), + Mockery::any(), + Mockery::any() + ); + + $plugin->register_settings(); + + static::assertInstanceOf( Closure::class, $captured ); + + $output = $this->capture( + $captured, + [ + 'label_for' => 'field1', + 'option_name' => 'option1', + ] + ); + + static::assertSame( 'FIELD_FIXTURE_OUTPUT:field1:option1', $output ); + } +} diff --git a/tests/fixtures/field.php b/tests/fixtures/field.php new file mode 100644 index 0000000..c5b77da --- /dev/null +++ b/tests/fixtures/field.php @@ -0,0 +1,20 @@ + -

This is the field 1 view.

+ +

diff --git a/views/section1.php b/views/section1.php index 0109ef0..589232f 100644 --- a/views/section1.php +++ b/views/section1.php @@ -4,10 +4,10 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ declare( strict_types = 1 ); ?> -

This is the section 1 view.

+