diff --git a/composer.json b/composer.json index 1dc24de5..bf36d7bd 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ ], "require": { "php": ">=8.2", - "rtcamp/wp-framework": "dev-main" + "rtcamp/wp-framework": "dev-feature/feature-selector-utility" }, "require-dev": { "wp-coding-standards/wpcs": "^2.3", @@ -34,6 +34,9 @@ "minimum-stability": "dev", "prefer-stable": true, "config": { + "platform": { + "php": "8.2" + }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true diff --git a/composer.lock b/composer.lock index 098d0733..83072c53 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db06e940e3f311fbccbb71fc2828d672", + "content-hash": "a950d9f20382ee2d3fae5bf72d5aec6d", "packages": [ { "name": "rtcamp/wp-framework", - "version": "dev-main", + "version": "dev-feature/feature-selector-utility", "source": { "type": "git", "url": "https://github.com/rtCamp/wp-framework.git", - "reference": "2e8fa2d5efd3940020b51b0a9c81f4488d3563eb" + "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/2e8fa2d5efd3940020b51b0a9c81f4488d3563eb", - "reference": "2e8fa2d5efd3940020b51b0a9c81f4488d3563eb", + "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/a29a8796c3c0c98c262289e10046d19d724f1f50", + "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50", "shasum": "" }, "require": { @@ -34,7 +34,6 @@ "wp-coding-standards/wpcs": "^3.1", "yoast/phpunit-polyfills": "^4.0" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -74,7 +73,7 @@ "issues": "https://github.com/rtCamp/wp-framework/issues", "source": "https://github.com/rtCamp/wp-framework" }, - "time": "2026-06-11T22:57:26+00:00" + "time": "2026-06-15T12:46:48+00:00" } ], "packages-dev": [ @@ -3205,12 +3204,12 @@ "source": { "type": "git", "url": "https://github.com/wp-cli/wp-cli.git", - "reference": "49ad9e440b86ed46da49cc2062970ade9c4d798f" + "reference": "dbc0753829b51253a6f54b2b106fde1226baaa03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/49ad9e440b86ed46da49cc2062970ade9c4d798f", - "reference": "49ad9e440b86ed46da49cc2062970ade9c4d798f", + "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/dbc0753829b51253a6f54b2b106fde1226baaa03", + "reference": "dbc0753829b51253a6f54b2b106fde1226baaa03", "shasum": "" }, "require": { @@ -3281,7 +3280,7 @@ "issues": "https://github.com/wp-cli/wp-cli/issues", "source": "https://github.com/wp-cli/wp-cli" }, - "time": "2026-06-03T21:33:03+00:00" + "time": "2026-06-12T14:22:28+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -3456,6 +3455,9 @@ "platform": { "php": ">=8.2" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.9.0" } diff --git a/inc/Core/Features.php b/inc/Core/Features.php new file mode 100644 index 00000000..00d17296 --- /dev/null +++ b/inc/Core/Features.php @@ -0,0 +1,99 @@ +get_shared() would re-enter + * the Singleton mid-construction, since the instance is only assigned after + * Main's constructor (and thus the Loader) finishes. A fresh instance is + * equivalent to the shared one: the registry is rebuilt identically in the + * constructor and all toggle state lives in options/constants. + * + * Flag labels are intentionally not translated here: this constructor runs + * when functions.php boots Main, before the theme text domain loads. The + * settings page reads {@see Features::get_features()} lazily at `admin_init`, + * so translated labels are supplied there instead. + * + * @since 1.0.0 + */ +final class Features extends FeatureSelector implements Shareable { + + /** + * Flag gating the AuthorBio shortcode module. + */ + public const AUTHOR_BIO = 'author-bio'; + + /** + * Flag gating the MediaTextInteractive block extension. + */ + public const MEDIA_TEXT_INTERACTIVE = 'media-text-interactive'; + + /** + * Constructor. + */ + public function __construct() { + parent::__construct( 'elementary' ); + + $this->register( + [ + self::AUTHOR_BIO, + self::MEDIA_TEXT_INTERACTIVE, + ] + ); + } + + /** + * {@inheritDoc} + * + * Merges translated display labels onto the registered flags. Safe to + * translate here: this is only read lazily (the settings page calls it at + * `admin_init`), after the theme text domain has loaded. + */ + public function get_features(): array { + $labels = [ + self::AUTHOR_BIO => [ + 'name' => __( 'Author bio shortcode', 'elementary-theme' ), + 'description' => __( 'Registers the [elementary_author_bio] shortcode rendering the author-bio template part.', 'elementary-theme' ), + ], + self::MEDIA_TEXT_INTERACTIVE => [ + 'name' => __( 'Interactive media & text', 'elementary-theme' ), + 'description' => __( 'Enhances core button, columns, and video blocks with the media-text interactivity behavior.', 'elementary-theme' ), + ], + ]; + + $features = parent::get_features(); + + foreach ( $labels as $slug => $meta ) { + if ( isset( $features[ $slug ] ) ) { + $features[ $slug ] = array_merge( $features[ $slug ], $meta ); + } + } + + return $features; + } +} diff --git a/inc/Helpers/Util.php b/inc/Helpers/Util.php index 2cc66995..db75710a 100644 --- a/inc/Helpers/Util.php +++ b/inc/Helpers/Util.php @@ -18,6 +18,7 @@ use rtCamp\Theme\Elementary\Core\Components; use rtCamp\Theme\Elementary\Core\Encryption; +use rtCamp\Theme\Elementary\Core\Features; use rtCamp\Theme\Elementary\Core\Templates; use rtCamp\Theme\Elementary\Main; @@ -158,4 +159,26 @@ private static function encryptor(): Encryption { return $encryptor; } + + /** + * Whether a theme feature flag is enabled. + * + * For use at hook time. Do not call during Main's load (constructors, + * can_register()) — the Singleton is not assigned yet; construct a + * Features instance directly there instead. + * + * @param string $flag Feature-flag slug, e.g. Features::AUTHOR_BIO. + * + * @return bool True if enabled, false otherwise. + */ + public static function is_feature_enabled( string $flag ): bool { + /** + * Shared feature-flag registry. + * + * @var Features $features + */ + $features = Main::get_instance()->get_shared( Features::class ); + + return $features->is_enabled( $flag ); + } } diff --git a/inc/Main.php b/inc/Main.php index 350d778e..b36fc64e 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -10,8 +10,8 @@ namespace rtCamp\Theme\Elementary; use rtCamp\WPFramework\Contracts\Traits\{Singleton, Loader}; -use rtCamp\Theme\Elementary\Core\{Assets, Components, Encryption, Menu, Templates, ThemeSetup}; -use rtCamp\Theme\Elementary\Modules\{BlockExtensions\MediaTextInteractive, Settings\ThemeOptions, Shortcodes\AuthorBio}; +use rtCamp\Theme\Elementary\Core\{Assets, Components, Encryption, Features, Menu, Templates, ThemeSetup}; +use rtCamp\Theme\Elementary\Modules\{BlockExtensions\MediaTextInteractive, Settings\FeaturesSettingsPage, Settings\ThemeOptions, Shortcodes\AuthorBio}; /** * Class Main @@ -33,8 +33,10 @@ class Main { Components::class, Templates::class, Encryption::class, + Features::class, MediaTextInteractive::class, ThemeOptions::class, + FeaturesSettingsPage::class, AuthorBio::class, ]; diff --git a/inc/Modules/BlockExtensions/MediaTextInteractive.php b/inc/Modules/BlockExtensions/MediaTextInteractive.php index 49a58382..009df79d 100644 --- a/inc/Modules/BlockExtensions/MediaTextInteractive.php +++ b/inc/Modules/BlockExtensions/MediaTextInteractive.php @@ -10,12 +10,28 @@ namespace rtCamp\Theme\Elementary\Modules\BlockExtensions; use WP_HTML_Tag_Processor; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\Theme\Elementary\Core\Features; +use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; /** * Class MediaTextInteractive + * + * Gated behind the `media-text-interactive` feature flag (Settings → + * Features), enabled by default; toggling the flag takes effect on the next + * request, since registration is decided once at load. */ -class MediaTextInteractive implements Registrable { +class MediaTextInteractive implements ConditionallyRegistrable { + + /** + * {@inheritDoc} + * + * Runs during Main's load — Util::is_feature_enabled() / get_shared() + * would re-enter the Singleton here, so construct a Features instance + * directly (see the Features docblock for why that is equivalent). + */ + public function can_register(): bool { + return ( new Features() )->is_enabled( Features::MEDIA_TEXT_INTERACTIVE ); + } /** * Register hooks. diff --git a/inc/Modules/Settings/FeaturesSettingsPage.php b/inc/Modules/Settings/FeaturesSettingsPage.php new file mode 100644 index 00000000..98f9d60e --- /dev/null +++ b/inc/Modules/Settings/FeaturesSettingsPage.php @@ -0,0 +1,51 @@ +is_enabled( Features::AUTHOR_BIO ); + } /** * Register hooks. diff --git a/tests/php/inc/Core/FeaturesTest.php b/tests/php/inc/Core/FeaturesTest.php new file mode 100644 index 00000000..027c2bdd --- /dev/null +++ b/tests/php/inc/Core/FeaturesTest.php @@ -0,0 +1,125 @@ +assertInstanceOf( FeatureSelector::class, $features ); + $this->assertInstanceOf( Shareable::class, $features ); + } + + /** + * It is shareable, registered in Main, and resolvable from the container. + */ + public function test_registered_and_shared_in_main(): void { + $this->assertContains( Features::class, Main::CLASSES ); + $this->assertInstanceOf( Features::class, Main::get_instance()->get_shared( Features::class ) ); + } + + /** + * The instance is namespaced with the theme's context slug. + */ + public function test_uses_elementary_context(): void { + $this->assertSame( 'elementary', ( new Features() )->get_context() ); + } + + /** + * Every theme flag is registered at construction. + */ + public function test_registers_theme_flags(): void { + $registered = ( new Features() )->get_registered(); + + $this->assertContains( Features::AUTHOR_BIO, $registered ); + $this->assertContains( Features::MEDIA_TEXT_INTERACTIVE, $registered ); + } + + /** + * Option keys and override constants derive from the context. + */ + public function test_key_derivation(): void { + $features = new Features(); + + $this->assertSame( 'elementary_feature_author_bio', $features->option_key( Features::AUTHOR_BIO ) ); + $this->assertSame( 'ELEMENTARY_FEATURE_AUTHOR_BIO', $features->constant_name( Features::AUTHOR_BIO ) ); + $this->assertSame( 'elementary_feature_media_text_interactive', $features->option_key( Features::MEDIA_TEXT_INTERACTIVE ) ); + } + + /** + * Flags default to enabled and follow the persisted option. + */ + public function test_is_enabled_follows_option(): void { + $features = new Features(); + + $this->assertTrue( $features->is_enabled( Features::AUTHOR_BIO ) ); + + update_option( $features->option_key( Features::AUTHOR_BIO ), true ); + $this->assertTrue( $features->is_enabled( Features::AUTHOR_BIO ) ); + + $features->disable( Features::AUTHOR_BIO ); + $this->assertFalse( $features->is_enabled( Features::AUTHOR_BIO ) ); + + $features->enable( Features::AUTHOR_BIO ); + $this->assertTrue( $features->is_enabled( Features::AUTHOR_BIO ) ); + } + + /** + * Two instances read the same state — the invariant that makes the + * `new Features()` instances in load-time consumers equivalent to the + * shared one. + */ + public function test_instances_are_interchangeable(): void { + $writer = new Features(); + $reader = new Features(); + + $writer->enable( Features::MEDIA_TEXT_INTERACTIVE ); + + $this->assertTrue( $reader->is_enabled( Features::MEDIA_TEXT_INTERACTIVE ) ); + $this->assertSame( $writer->get_registered(), $reader->get_registered() ); + } + + /** + * Display metadata is resolved lazily with non-empty labels for every flag. + */ + public function test_get_features_provides_labels(): void { + foreach ( ( new Features() )->get_features() as $slug => $meta ) { + $this->assertSame( $slug, $meta['slug'] ); + $this->assertNotSame( '', $meta['name'] ); + $this->assertNotSame( $slug, $meta['name'], "Flag {$slug} should have a human-readable name." ); + $this->assertNotSame( '', $meta['description'] ); + } + } + + /** + * Util::is_feature_enabled() proxies the shared instance. + */ + public function test_util_helper_reads_flags(): void { + $this->assertTrue( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + + ( new Features() )->disable( Features::AUTHOR_BIO ); + + $this->assertFalse( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + } +} diff --git a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php new file mode 100644 index 00000000..413eb4aa --- /dev/null +++ b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php @@ -0,0 +1,60 @@ +instance = new MediaTextInteractive(); + } + + /** + * The module is gated behind the `media-text-interactive` feature flag: + * on by default, off once the flag is disabled. + */ + public function test_registration_is_gated_by_feature_flag(): void { + $this->assertInstanceOf( ConditionallyRegistrable::class, $this->instance ); + $this->assertTrue( $this->instance->can_register() ); + + ( new Features() )->disable( Features::MEDIA_TEXT_INTERACTIVE ); + + $this->assertFalse( $this->instance->can_register() ); + } + + /** + * The block render filters are attached by register_hooks(). + */ + public function test_registers_block_render_filters(): void { + $this->instance->register_hooks(); + + $this->assertNotFalse( has_filter( 'render_block_core/button', [ $this->instance, 'render_block_core_button' ] ) ); + $this->assertNotFalse( has_filter( 'render_block_core/columns', [ $this->instance, 'render_block_core_columns' ] ) ); + $this->assertNotFalse( has_filter( 'render_block_core/video', [ $this->instance, 'render_block_core_video' ] ) ); + } +} diff --git a/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php new file mode 100644 index 00000000..8756db40 --- /dev/null +++ b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php @@ -0,0 +1,69 @@ +assertInstanceOf( FeatureSelectorSettingsPage::class, new FeaturesSettingsPage() ); + } + + /** + * It is registered in Main so the Loader boots it. + */ + public function test_registered_in_main(): void { + $this->assertContains( FeaturesSettingsPage::class, Main::CLASSES ); + } + + /** + * The page is driven by the theme's Features selector, so its slug, + * option group, and option keys all carry the `elementary` context. + */ + public function test_page_is_driven_by_theme_features(): void { + $page = new FeaturesSettingsPage(); + $selector = ( new ReflectionProperty( FeatureSelectorSettingsPage::class, 'selector' ) )->getValue( $page ); + + $this->assertInstanceOf( Features::class, $selector ); + } + + /** + * One boolean setting is registered per theme flag. + */ + public function test_registers_one_setting_per_flag(): void { + $page = new FeaturesSettingsPage(); + $page->register_settings(); + + $registered = get_registered_settings(); + $features = new Features(); + + foreach ( $features->get_registered() as $slug ) { + $option_key = $features->option_key( $slug ); + + $this->assertArrayHasKey( $option_key, $registered ); + $this->assertSame( 'boolean', $registered[ $option_key ]['type'] ); + } + } +} diff --git a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php index d4470188..ff13e8cf 100644 --- a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php +++ b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php @@ -8,7 +8,9 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; +use rtCamp\Theme\Elementary\Core\Features; use rtCamp\Theme\Elementary\Modules\Shortcodes\AuthorBio; +use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; /** * Class AuthorBioTest @@ -43,6 +45,19 @@ public function test_registers_shortcode(): void { $this->assertTrue( shortcode_exists( 'elementary_author_bio' ) ); } + /** + * The module is gated behind the `author-bio` feature flag: on by + * default, off once the flag is disabled. + */ + public function test_registration_is_gated_by_feature_flag(): void { + $this->assertInstanceOf( ConditionallyRegistrable::class, $this->instance ); + $this->assertTrue( $this->instance->can_register() ); + + ( new Features() )->disable( Features::AUTHOR_BIO ); + + $this->assertFalse( $this->instance->can_register() ); + } + /** * It renders the author-bio part for a real user. */