From 227cad6808d4f079ea8e7b7cbeeb73da679c2247 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Sat, 13 Jun 2026 01:56:01 +0530 Subject: [PATCH 1/8] feat: add feature-flag system over framework FeatureSelector - Features class (Shareable) registers author-bio & media-text-interactive flags - FeaturesSettingsPage renders checkbox UI for each flag - AuthorBio + MediaTextInteractive changed to ConditionallyRegistrable, gated behind their respective flags - Framework pinned to dev-feature/feature-selector-utility --- composer.json | 2 +- composer.lock | 17 ++- inc/Core/Features.php | 97 ++++++++++++++ inc/Helpers/Util.php | 23 ++++ inc/Main.php | 6 +- .../BlockExtensions/MediaTextInteractive.php | 20 ++- inc/Modules/Settings/FeaturesSettingsPage.php | 51 +++++++ inc/Modules/Shortcodes/AuthorBio.php | 20 ++- tests/php/inc/Core/FeaturesTest.php | 125 ++++++++++++++++++ .../MediaTextInteractiveTest.php | 60 +++++++++ .../Settings/FeaturesSettingsPageTest.php | 69 ++++++++++ .../inc/Modules/Shortcodes/AuthorBioTest.php | 15 +++ 12 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 inc/Core/Features.php create mode 100644 inc/Modules/Settings/FeaturesSettingsPage.php create mode 100644 tests/php/inc/Core/FeaturesTest.php create mode 100644 tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php create mode 100644 tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php diff --git a/composer.json b/composer.json index 1dc24de5..7251ab88 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", diff --git a/composer.lock b/composer.lock index 098d0733..593b5256 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": "c58adb26698dbfb6ee8fc7b934672b75", "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": "b8b5a2b31f3c4db096037d8f70622dc3acab5192" }, "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/b8b5a2b31f3c4db096037d8f70622dc3acab5192", + "reference": "b8b5a2b31f3c4db096037d8f70622dc3acab5192", "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-12T16:03:48+00:00" } ], "packages-dev": [ @@ -3456,6 +3455,6 @@ "platform": { "php": ">=8.2" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/inc/Core/Features.php b/inc/Core/Features.php new file mode 100644 index 00000000..22ce5590 --- /dev/null +++ b/inc/Core/Features.php @@ -0,0 +1,97 @@ +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..99d8dd89 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), disabled 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..8afd11bf --- /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 disabled and follow the persisted option. + */ + public function test_is_enabled_follows_option(): void { + $features = new Features(); + + $this->assertFalse( $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->assertFalse( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + + ( new Features() )->enable( Features::AUTHOR_BIO ); + + $this->assertTrue( 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..b7d0ea24 --- /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: + * off by default, on once the flag is enabled. + */ + public function test_registration_is_gated_by_feature_flag(): void { + $this->assertInstanceOf( ConditionallyRegistrable::class, $this->instance ); + $this->assertFalse( $this->instance->can_register() ); + + ( new Features() )->enable( Features::MEDIA_TEXT_INTERACTIVE ); + + $this->assertTrue( $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..b2b62cf1 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: off by + * default, on once the flag is enabled. + */ + public function test_registration_is_gated_by_feature_flag(): void { + $this->assertInstanceOf( ConditionallyRegistrable::class, $this->instance ); + $this->assertFalse( $this->instance->can_register() ); + + ( new Features() )->enable( Features::AUTHOR_BIO ); + + $this->assertTrue( $this->instance->can_register() ); + } + /** * It renders the author-bio part for a real user. */ From 86fec415b772f5a90642f7513844dbf8844c4804 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 15 Jun 2026 18:36:04 +0530 Subject: [PATCH 2/8] feat: update feature flags to default to enabled across modules and tests --- composer.lock | 39 +++++++++---------- inc/Core/Features.php | 4 +- .../BlockExtensions/MediaTextInteractive.php | 2 +- inc/Modules/Shortcodes/AuthorBio.php | 2 +- tests/php/inc/Core/FeaturesTest.php | 10 ++--- .../MediaTextInteractiveTest.php | 8 ++-- .../inc/Modules/Shortcodes/AuthorBioTest.php | 10 ++--- 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/composer.lock b/composer.lock index 593b5256..370b3f16 100644 --- a/composer.lock +++ b/composer.lock @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/rtCamp/wp-framework.git", - "reference": "b8b5a2b31f3c4db096037d8f70622dc3acab5192" + "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/b8b5a2b31f3c4db096037d8f70622dc3acab5192", - "reference": "b8b5a2b31f3c4db096037d8f70622dc3acab5192", + "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/a29a8796c3c0c98c262289e10046d19d724f1f50", + "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50", "shasum": "" }, "require": { @@ -73,7 +73,7 @@ "issues": "https://github.com/rtCamp/wp-framework/issues", "source": "https://github.com/rtCamp/wp-framework" }, - "time": "2026-06-12T16:03:48+00:00" + "time": "2026-06-15T12:46:48+00:00" } ], "packages-dev": [ @@ -228,30 +228,29 @@ }, { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.4" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^14", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -278,7 +277,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { @@ -294,7 +293,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { "name": "eftec/bladeone", @@ -3204,12 +3203,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": { @@ -3280,7 +3279,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", diff --git a/inc/Core/Features.php b/inc/Core/Features.php index 22ce5590..00d17296 100644 --- a/inc/Core/Features.php +++ b/inc/Core/Features.php @@ -23,7 +23,9 @@ * - override constant: ELEMENTARY_FEATURE_{FLAG} (wp-config.php; wins over * the option and locks the admin checkbox) * - * Flags default to disabled. Read them at hook time through + * Flags default to enabled — features ship on; the selector exists to turn + * them off (a stored option or an ELEMENTARY_FEATURE_{FLAG} constant overrides + * that default). Read them at hook time through * Helpers\Util::is_feature_enabled(); load-time consumers (constructors, * ConditionallyRegistrable::can_register()) must construct their own * `new Features()` instead — Main::get_instance()->get_shared() would re-enter diff --git a/inc/Modules/BlockExtensions/MediaTextInteractive.php b/inc/Modules/BlockExtensions/MediaTextInteractive.php index 99d8dd89..009df79d 100644 --- a/inc/Modules/BlockExtensions/MediaTextInteractive.php +++ b/inc/Modules/BlockExtensions/MediaTextInteractive.php @@ -17,7 +17,7 @@ * Class MediaTextInteractive * * Gated behind the `media-text-interactive` feature flag (Settings → - * Features), disabled by default; toggling the flag takes effect on the next + * Features), enabled by default; toggling the flag takes effect on the next * request, since registration is decided once at load. */ class MediaTextInteractive implements ConditionallyRegistrable { diff --git a/inc/Modules/Shortcodes/AuthorBio.php b/inc/Modules/Shortcodes/AuthorBio.php index ef1886b1..4dba6383 100644 --- a/inc/Modules/Shortcodes/AuthorBio.php +++ b/inc/Modules/Shortcodes/AuthorBio.php @@ -21,7 +21,7 @@ * template part through Util::get_template() — a child theme can override the * markup by shipping its own template-parts/author-bio.php. * - * Gated behind the `author-bio` feature flag (Settings → Features), disabled + * Gated behind the `author-bio` feature flag (Settings → Features), enabled * by default; toggling the flag takes effect on the next request, since * registration is decided once at load. * diff --git a/tests/php/inc/Core/FeaturesTest.php b/tests/php/inc/Core/FeaturesTest.php index 8afd11bf..027c2bdd 100644 --- a/tests/php/inc/Core/FeaturesTest.php +++ b/tests/php/inc/Core/FeaturesTest.php @@ -68,12 +68,12 @@ public function test_key_derivation(): void { } /** - * Flags default to disabled and follow the persisted option. + * Flags default to enabled and follow the persisted option. */ public function test_is_enabled_follows_option(): void { $features = new Features(); - $this->assertFalse( $features->is_enabled( Features::AUTHOR_BIO ) ); + $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 ) ); @@ -116,10 +116,10 @@ public function test_get_features_provides_labels(): void { * Util::is_feature_enabled() proxies the shared instance. */ public function test_util_helper_reads_flags(): void { - $this->assertFalse( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + $this->assertTrue( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); - ( new Features() )->enable( Features::AUTHOR_BIO ); + ( new Features() )->disable( Features::AUTHOR_BIO ); - $this->assertTrue( Util::is_feature_enabled( 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 index b7d0ea24..413eb4aa 100644 --- a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php +++ b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php @@ -36,15 +36,15 @@ public function set_up(): void { /** * The module is gated behind the `media-text-interactive` feature flag: - * off by default, on once the flag is enabled. + * 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->assertFalse( $this->instance->can_register() ); + $this->assertTrue( $this->instance->can_register() ); - ( new Features() )->enable( Features::MEDIA_TEXT_INTERACTIVE ); + ( new Features() )->disable( Features::MEDIA_TEXT_INTERACTIVE ); - $this->assertTrue( $this->instance->can_register() ); + $this->assertFalse( $this->instance->can_register() ); } /** diff --git a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php index b2b62cf1..ff13e8cf 100644 --- a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php +++ b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php @@ -46,16 +46,16 @@ public function test_registers_shortcode(): void { } /** - * The module is gated behind the `author-bio` feature flag: off by - * default, on once the flag is enabled. + * 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->assertFalse( $this->instance->can_register() ); + $this->assertTrue( $this->instance->can_register() ); - ( new Features() )->enable( Features::AUTHOR_BIO ); + ( new Features() )->disable( Features::AUTHOR_BIO ); - $this->assertTrue( $this->instance->can_register() ); + $this->assertFalse( $this->instance->can_register() ); } /** From f2f46d5f870a528fd9e9c8b71a032ce2cd19e16c Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 15 Jun 2026 18:58:07 +0530 Subject: [PATCH 3/8] fix(deps): pin composer platform to PHP 8.2; restore doctrine/instantiator 2.0.0 --- composer.json | 3 +++ composer.lock | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 7251ab88..bf36d7bd 100644 --- a/composer.json +++ b/composer.json @@ -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 370b3f16..83072c53 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c58adb26698dbfb6ee8fc7b934672b75", + "content-hash": "a950d9f20382ee2d3fae5bf72d5aec6d", "packages": [ { "name": "rtcamp/wp-framework", @@ -228,29 +228,30 @@ }, { "name": "doctrine/instantiator", - "version": "2.1.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", - "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^14", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5.58" + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -277,7 +278,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -293,7 +294,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T06:47:08+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "eftec/bladeone", @@ -3455,5 +3456,8 @@ "php": ">=8.2" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, "plugin-api-version": "2.9.0" } From 99ad7ab54f8be511b04c54209b1701ca9c7ebbe8 Mon Sep 17 00:00:00 2001 From: pratik-londhe4 Date: Wed, 24 Jun 2026 03:42:00 +0530 Subject: [PATCH 4/8] Update to use the new FeatureFlag system --- composer.json | 2 +- composer.lock | 25 +++--- inc/Core/FeatureRegistry.php | 35 ++++++++ inc/Core/ThemeFeature.php | 32 +++++++ inc/Helpers/Util.php | 16 ++-- inc/Main.php | 5 +- .../BlockExtensions/MediaTextInteractive.php | 27 +++--- inc/Modules/Settings/FeaturesSettingsPage.php | 13 ++- inc/Modules/Shortcodes/AuthorBio.php | 28 +++--- tests/php/inc/Core/FeaturesTest.php | 90 +++++++++---------- .../MediaTextInteractiveTest.php | 5 +- .../Settings/FeaturesSettingsPageTest.php | 25 ++---- .../inc/Modules/Shortcodes/AuthorBioTest.php | 5 +- 13 files changed, 180 insertions(+), 128 deletions(-) create mode 100644 inc/Core/FeatureRegistry.php create mode 100644 inc/Core/ThemeFeature.php diff --git a/composer.json b/composer.json index bf36d7bd..3550d4d6 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ ], "require": { "php": ">=8.2", - "rtcamp/wp-framework": "dev-feature/feature-selector-utility" + "rtcamp/wp-framework": "dev-feat/feature-selector" }, "require-dev": { "wp-coding-standards/wpcs": "^2.3", diff --git a/composer.lock b/composer.lock index 83072c53..02dd0932 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": "a950d9f20382ee2d3fae5bf72d5aec6d", + "content-hash": "d7c64834bac097fc670b388860ea030f", "packages": [ { "name": "rtcamp/wp-framework", - "version": "dev-feature/feature-selector-utility", + "version": "dev-feat/feature-selector", "source": { "type": "git", "url": "https://github.com/rtCamp/wp-framework.git", - "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50" + "reference": "29af87bc0601e41247892e76d44a06b788167d20" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/a29a8796c3c0c98c262289e10046d19d724f1f50", - "reference": "a29a8796c3c0c98c262289e10046d19d724f1f50", + "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/29af87bc0601e41247892e76d44a06b788167d20", + "reference": "29af87bc0601e41247892e76d44a06b788167d20", "shasum": "" }, "require": { @@ -28,10 +28,11 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.1", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^11.0", + "phpunit/phpunit": "^9.6", "squizlabs/php_codesniffer": "^3.10", "szepeviktor/phpstan-wordpress": "^2.0", "wp-coding-standards/wpcs": "^3.1", + "wp-phpunit/wp-phpunit": "^6.9", "yoast/phpunit-polyfills": "^4.0" }, "type": "library", @@ -73,7 +74,7 @@ "issues": "https://github.com/rtCamp/wp-framework/issues", "source": "https://github.com/rtCamp/wp-framework" }, - "time": "2026-06-15T12:46:48+00:00" + "time": "2026-06-23T21:43:08+00:00" } ], "packages-dev": [ @@ -3204,12 +3205,12 @@ "source": { "type": "git", "url": "https://github.com/wp-cli/wp-cli.git", - "reference": "dbc0753829b51253a6f54b2b106fde1226baaa03" + "reference": "fc140030b5c1a47020812b74e2b8d767d7242725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/dbc0753829b51253a6f54b2b106fde1226baaa03", - "reference": "dbc0753829b51253a6f54b2b106fde1226baaa03", + "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/fc140030b5c1a47020812b74e2b8d767d7242725", + "reference": "fc140030b5c1a47020812b74e2b8d767d7242725", "shasum": "" }, "require": { @@ -3280,7 +3281,7 @@ "issues": "https://github.com/wp-cli/wp-cli/issues", "source": "https://github.com/wp-cli/wp-cli" }, - "time": "2026-06-12T14:22:28+00:00" + "time": "2026-06-18T15:36:01+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -3459,5 +3460,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/inc/Core/FeatureRegistry.php b/inc/Core/FeatureRegistry.php new file mode 100644 index 00000000..8bb964f1 --- /dev/null +++ b/inc/Core/FeatureRegistry.php @@ -0,0 +1,35 @@ + bool}) + * Override constants: ELEMENTARY_FEATURE_{FLAG} + * + * @since 1.0.0 + */ +final class FeatureRegistry extends FeatureSelector implements Shareable { + + /** + * Constructor. + */ + public function __construct() { + parent::__construct( 'elementary' ); + } +} diff --git a/inc/Core/ThemeFeature.php b/inc/Core/ThemeFeature.php new file mode 100644 index 00000000..9b600a4e --- /dev/null +++ b/inc/Core/ThemeFeature.php @@ -0,0 +1,32 @@ +get_shared( FeatureRegistry::class ); + } +} diff --git a/inc/Helpers/Util.php b/inc/Helpers/Util.php index db75710a..90bd1c1d 100644 --- a/inc/Helpers/Util.php +++ b/inc/Helpers/Util.php @@ -18,7 +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\FeatureRegistry; use rtCamp\Theme\Elementary\Core\Templates; use rtCamp\Theme\Elementary\Main; @@ -163,21 +163,19 @@ private static function encryptor(): Encryption { /** * 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. + * Delegates to the shared FeatureRegistry. Safe to call at hook time; + * do not call from constructors during Main::load() since that runs before + * hook registration — use can_register() via AbstractFeature instead. * - * @param string $flag Feature-flag slug, e.g. Features::AUTHOR_BIO. + * @param string $flag Feature-flag slug, e.g. '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 + * @var FeatureRegistry $features */ - $features = Main::get_instance()->get_shared( Features::class ); + $features = Main::get_instance()->get_shared( FeatureRegistry::class ); return $features->is_enabled( $flag ); } diff --git a/inc/Main.php b/inc/Main.php index b36fc64e..580997db 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -10,7 +10,7 @@ namespace rtCamp\Theme\Elementary; use rtCamp\WPFramework\Contracts\Traits\{Singleton, Loader}; -use rtCamp\Theme\Elementary\Core\{Assets, Components, Encryption, Features, Menu, Templates, ThemeSetup}; +use rtCamp\Theme\Elementary\Core\{Assets, Components, Encryption, FeatureRegistry, Menu, Templates, ThemeSetup}; use rtCamp\Theme\Elementary\Modules\{BlockExtensions\MediaTextInteractive, Settings\FeaturesSettingsPage, Settings\ThemeOptions, Shortcodes\AuthorBio}; /** @@ -33,7 +33,7 @@ class Main { Components::class, Templates::class, Encryption::class, - Features::class, + FeatureRegistry::class, MediaTextInteractive::class, ThemeOptions::class, FeaturesSettingsPage::class, @@ -44,6 +44,7 @@ class Main { * Constructor. */ protected function __construct() { + static::$instance = $this; $this->load( self::CLASSES ); } } diff --git a/inc/Modules/BlockExtensions/MediaTextInteractive.php b/inc/Modules/BlockExtensions/MediaTextInteractive.php index 009df79d..129a4a34 100644 --- a/inc/Modules/BlockExtensions/MediaTextInteractive.php +++ b/inc/Modules/BlockExtensions/MediaTextInteractive.php @@ -10,8 +10,7 @@ namespace rtCamp\Theme\Elementary\Modules\BlockExtensions; use WP_HTML_Tag_Processor; -use rtCamp\Theme\Elementary\Core\Features; -use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; +use rtCamp\Theme\Elementary\Core\ThemeFeature; /** * Class MediaTextInteractive @@ -20,21 +19,24 @@ * Features), enabled by default; toggling the flag takes effect on the next * request, since registration is decided once at load. */ -class MediaTextInteractive implements ConditionallyRegistrable { +class MediaTextInteractive extends ThemeFeature { + + /** + * {@inheritDoc} + */ + protected function get_slug(): string { + return 'media-text-interactive'; + } /** * {@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 ); + protected function get_description(): string { + return __( 'Adds interactive behaviour to the core Media & Text block — play/pause controls, scroll-triggered video, and column layout enhancements.', 'elementary-theme' ); } /** - * Register hooks. + * {@inheritDoc} */ public function register_hooks(): void { add_filter( 'render_block_core/button', [ $this, 'render_block_core_button' ], 10, 2 ); @@ -76,10 +78,6 @@ public function render_block_core_columns( string $block_content, array $block ) return $block_content; } - /** - * Enqueue the module script, The prefix `@` is used to indicate that the script is a module. - * This handle with the prefix `@` will be used in other scripts to import this module. - */ wp_enqueue_script_module( '@elementary/media-text', sprintf( '%s/js/modules/media-text.js', ELEMENTARY_THEME_BUILD_URI ), @@ -109,6 +107,7 @@ public function render_block_core_video( string $block_content, array $block ): if ( ! isset( $block['attrs']['className'] ) || ! str_contains( $block['attrs']['className'], 'elementary-media-text-interactive' ) ) { return $block_content; } + $p = new WP_HTML_Tag_Processor( $block_content ); $p->next_tag(); diff --git a/inc/Modules/Settings/FeaturesSettingsPage.php b/inc/Modules/Settings/FeaturesSettingsPage.php index 98f9d60e..75af23e7 100644 --- a/inc/Modules/Settings/FeaturesSettingsPage.php +++ b/inc/Modules/Settings/FeaturesSettingsPage.php @@ -9,7 +9,8 @@ namespace rtCamp\Theme\Elementary\Modules\Settings; -use rtCamp\Theme\Elementary\Core\Features; +use rtCamp\Theme\Elementary\Core\FeatureRegistry; +use rtCamp\Theme\Elementary\Main; use rtCamp\WPFramework\Utils\FeatureSelectorSettingsPage; /** @@ -25,14 +26,10 @@ final class FeaturesSettingsPage extends FeatureSelectorSettingsPage { /** - * Constructor. - * - * The Loader instantiates without arguments, so the selector cannot be - * injected; see the Features docblock for why a fresh instance is - * equivalent to the shared one. + * {@inheritDoc} */ - public function __construct() { - parent::__construct( new Features() ); + protected function get_selector(): FeatureRegistry { + return Main::get_instance()->get_shared( FeatureRegistry::class ); } /** diff --git a/inc/Modules/Shortcodes/AuthorBio.php b/inc/Modules/Shortcodes/AuthorBio.php index 4dba6383..2f639489 100644 --- a/inc/Modules/Shortcodes/AuthorBio.php +++ b/inc/Modules/Shortcodes/AuthorBio.php @@ -9,17 +9,14 @@ namespace rtCamp\Theme\Elementary\Modules\Shortcodes; -use rtCamp\Theme\Elementary\Core\Features; +use rtCamp\Theme\Elementary\Core\ThemeFeature; use rtCamp\Theme\Elementary\Helpers\Util; -use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; /** * Class AuthorBio * - * Example consumer of the theme's TemplateLoader. Registers the - * `[elementary_author_bio]` shortcode, which renders the `author-bio` - * template part through Util::get_template() — a child theme can override the - * markup by shipping its own template-parts/author-bio.php. + * Registers the [elementary_author_bio] shortcode, which renders the + * `author-bio` template part through Util::get_template(). * * Gated behind the `author-bio` feature flag (Settings → Features), enabled * by default; toggling the flag takes effect on the next request, since @@ -27,21 +24,24 @@ * * @since 1.0.0 */ -final class AuthorBio implements ConditionallyRegistrable { +final class AuthorBio extends ThemeFeature { /** * {@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::AUTHOR_BIO ); + protected function get_slug(): string { + return 'author-bio'; } /** - * Register hooks. + * {@inheritDoc} + */ + protected function get_description(): string { + return __( 'Enables the [elementary_author_bio] shortcode, which renders an author biography block on posts and pages.', 'elementary-theme' ); + } + + /** + * {@inheritDoc} * * @since 1.0.0 */ diff --git a/tests/php/inc/Core/FeaturesTest.php b/tests/php/inc/Core/FeaturesTest.php index 027c2bdd..bc434c96 100644 --- a/tests/php/inc/Core/FeaturesTest.php +++ b/tests/php/inc/Core/FeaturesTest.php @@ -1,6 +1,6 @@ assertInstanceOf( FeatureSelector::class, $features ); - $this->assertInstanceOf( Shareable::class, $features ); + $this->assertInstanceOf( FeatureSelector::class, $registry ); + $this->assertInstanceOf( Shareable::class, $registry ); } /** - * It is shareable, registered in Main, and resolvable from the container. + * It is registered in Main before the feature classes that depend on it. */ 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 ) ); + $this->assertContains( FeatureRegistry::class, Main::CLASSES ); + $this->assertInstanceOf( FeatureRegistry::class, Main::get_instance()->get_shared( FeatureRegistry::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() ); + $this->assertSame( 'elementary', ( new FeatureRegistry() )->get_context() ); } /** - * Every theme flag is registered at construction. + * Every theme feature self-registers at construction, so the shared instance + * already has the expected flags by the time tests run (Main is booted in + * the test bootstrap). */ public function test_registers_theme_flags(): void { - $registered = ( new Features() )->get_registered(); + $registered = Main::get_instance()->get_shared( FeatureRegistry::class )->get_registered(); - $this->assertContains( Features::AUTHOR_BIO, $registered ); - $this->assertContains( Features::MEDIA_TEXT_INTERACTIVE, $registered ); + $this->assertContains( 'author-bio', $registered ); + $this->assertContains( 'media-text-interactive', $registered ); } /** - * Option keys and override constants derive from the context. + * Shared option key and override constants derive from the context. */ public function test_key_derivation(): void { - $features = new Features(); + $registry = new FeatureRegistry(); - $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 ) ); + $this->assertSame( 'elementary_features', $registry->shared_option_key() ); + $this->assertSame( 'author-bio', $registry->flag_key( 'author-bio' ) ); + $this->assertSame( 'ELEMENTARY_FEATURE_AUTHOR_BIO', $registry->constant_name( 'author-bio' ) ); + $this->assertSame( 'ELEMENTARY_FEATURE_MEDIA_TEXT_INTERACTIVE', $registry->constant_name( 'media-text-interactive' ) ); } /** * Flags default to enabled and follow the persisted option. */ public function test_is_enabled_follows_option(): void { - $features = new Features(); + $registry = Main::get_instance()->get_shared( FeatureRegistry::class ); - $this->assertTrue( $features->is_enabled( Features::AUTHOR_BIO ) ); + $this->assertTrue( $registry->is_enabled( 'author-bio' ) ); - update_option( $features->option_key( Features::AUTHOR_BIO ), true ); - $this->assertTrue( $features->is_enabled( Features::AUTHOR_BIO ) ); + $registry->disable( 'author-bio' ); + $this->assertFalse( $registry->is_enabled( '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 ) ); + $registry->enable( 'author-bio' ); + $this->assertTrue( $registry->is_enabled( '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. + * All flags share a single option row; there are no per-flag option rows. */ - public function test_instances_are_interchangeable(): void { - $writer = new Features(); - $reader = new Features(); + public function test_flags_share_one_option_row(): void { + $registry = Main::get_instance()->get_shared( FeatureRegistry::class ); - $writer->enable( Features::MEDIA_TEXT_INTERACTIVE ); + $registry->enable( 'author-bio' ); + $registry->disable( 'media-text-interactive' ); - $this->assertTrue( $reader->is_enabled( Features::MEDIA_TEXT_INTERACTIVE ) ); - $this->assertSame( $writer->get_registered(), $reader->get_registered() ); + $stored = get_option( 'elementary_features' ); + $this->assertTrue( $stored['author-bio'] ); + $this->assertFalse( $stored['media-text-interactive'] ); + $this->assertFalse( get_option( 'elementary_feature_author_bio', false ) ); } /** - * Display metadata is resolved lazily with non-empty labels for every flag. + * Display metadata is resolved on construction with non-empty labels. */ public function test_get_features_provides_labels(): void { - foreach ( ( new Features() )->get_features() as $slug => $meta ) { + foreach ( Main::get_instance()->get_shared( FeatureRegistry::class )->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." ); @@ -113,13 +113,13 @@ public function test_get_features_provides_labels(): void { } /** - * Util::is_feature_enabled() proxies the shared instance. + * Util::is_feature_enabled() proxies the shared registry instance. */ public function test_util_helper_reads_flags(): void { - $this->assertTrue( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + $this->assertTrue( Util::is_feature_enabled( 'author-bio' ) ); - ( new Features() )->disable( Features::AUTHOR_BIO ); + Main::get_instance()->get_shared( FeatureRegistry::class )->disable( 'author-bio' ); - $this->assertFalse( Util::is_feature_enabled( Features::AUTHOR_BIO ) ); + $this->assertFalse( Util::is_feature_enabled( 'author-bio' ) ); } } diff --git a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php index 413eb4aa..848b2114 100644 --- a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php +++ b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php @@ -8,7 +8,8 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; -use rtCamp\Theme\Elementary\Core\Features; +use rtCamp\Theme\Elementary\Core\FeatureRegistry; +use rtCamp\Theme\Elementary\Main; use rtCamp\Theme\Elementary\Modules\BlockExtensions\MediaTextInteractive; use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; @@ -42,7 +43,7 @@ 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 ); + Main::get_instance()->get_shared( FeatureRegistry::class )->disable( 'media-text-interactive' ); $this->assertFalse( $this->instance->can_register() ); } diff --git a/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php index 8756db40..e279ec1d 100644 --- a/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php +++ b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php @@ -8,7 +8,6 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; -use rtCamp\Theme\Elementary\Core\Features; use rtCamp\Theme\Elementary\Main; use rtCamp\Theme\Elementary\Modules\Settings\FeaturesSettingsPage; use rtCamp\WPFramework\Utils\FeatureSelectorSettingsPage; @@ -39,31 +38,19 @@ public function test_registered_in_main(): void { } /** - * The page is driven by the theme's Features selector, so its slug, - * option group, and option keys all carry the `elementary` context. + * The page is driven by the theme's FeatureRegistry (context 'elementary'), + * so it registers the single shared option `elementary_features`. */ public function test_page_is_driven_by_theme_features(): void { - $page = new FeaturesSettingsPage(); - $selector = ( new ReflectionProperty( FeatureSelectorSettingsPage::class, 'selector' ) )->getValue( $page ); + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; - $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'] ); - } + $this->assertArrayHasKey( 'elementary_features', $registered ); + $this->assertSame( 'array', $registered['elementary_features']['type'] ); } } diff --git a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php index ff13e8cf..cfa9d883 100644 --- a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php +++ b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php @@ -8,7 +8,8 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; -use rtCamp\Theme\Elementary\Core\Features; +use rtCamp\Theme\Elementary\Core\FeatureRegistry; +use rtCamp\Theme\Elementary\Main; use rtCamp\Theme\Elementary\Modules\Shortcodes\AuthorBio; use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; @@ -53,7 +54,7 @@ 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 ); + Main::get_instance()->get_shared( FeatureRegistry::class )->disable( 'author-bio' ); $this->assertFalse( $this->instance->can_register() ); } From d0a6ce41d01d0bc5ac8068cc4f724cc1ea6366c2 Mon Sep 17 00:00:00 2001 From: pratik-londhe4 Date: Wed, 24 Jun 2026 04:32:28 +0530 Subject: [PATCH 5/8] Rename ThemeFeature to AbstracThemeFeature --- .../AbstractThemeFeature.php} | 9 +- inc/Core/Features.php | 99 ------------------- .../BlockExtensions/MediaTextInteractive.php | 4 +- inc/Modules/Shortcodes/AuthorBio.php | 4 +- 4 files changed, 9 insertions(+), 107 deletions(-) rename inc/{Core/ThemeFeature.php => Abstracts/AbstractThemeFeature.php} (69%) delete mode 100644 inc/Core/Features.php diff --git a/inc/Core/ThemeFeature.php b/inc/Abstracts/AbstractThemeFeature.php similarity index 69% rename from inc/Core/ThemeFeature.php rename to inc/Abstracts/AbstractThemeFeature.php index 9b600a4e..0c07df4b 100644 --- a/inc/Core/ThemeFeature.php +++ b/inc/Abstracts/AbstractThemeFeature.php @@ -2,26 +2,27 @@ /** * Theme feature base class. * - * @package rtCamp\Theme\Elementary\Core + * @package rtCamp\Theme\Elementary\Abstracts */ declare( strict_types = 1 ); -namespace rtCamp\Theme\Elementary\Core; +namespace rtCamp\Theme\Elementary\Abstracts; use rtCamp\WPFramework\Contracts\Abstracts\AbstractFeature; use rtCamp\WPFramework\Utils\FeatureSelector; use rtCamp\Theme\Elementary\Main; +use rtCamp\Theme\Elementary\Core\FeatureRegistry; /** - * Class ThemeFeature + * Class AbstractThemeFeature * * Abstract base for all theme features. Wires AbstractFeature to the theme's * shared FeatureRegistry via Main::get_shared(). * * @since 1.0.0 */ -abstract class ThemeFeature extends AbstractFeature { +abstract class AbstractThemeFeature extends AbstractFeature { /** * {@inheritDoc} diff --git a/inc/Core/Features.php b/inc/Core/Features.php deleted file mode 100644 index 00d17296..00000000 --- a/inc/Core/Features.php +++ /dev/null @@ -1,99 +0,0 @@ -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/Modules/BlockExtensions/MediaTextInteractive.php b/inc/Modules/BlockExtensions/MediaTextInteractive.php index 129a4a34..00068bd7 100644 --- a/inc/Modules/BlockExtensions/MediaTextInteractive.php +++ b/inc/Modules/BlockExtensions/MediaTextInteractive.php @@ -10,7 +10,7 @@ namespace rtCamp\Theme\Elementary\Modules\BlockExtensions; use WP_HTML_Tag_Processor; -use rtCamp\Theme\Elementary\Core\ThemeFeature; +use rtCamp\Theme\Elementary\Abstracts\AbstractThemeFeature; /** * Class MediaTextInteractive @@ -19,7 +19,7 @@ * Features), enabled by default; toggling the flag takes effect on the next * request, since registration is decided once at load. */ -class MediaTextInteractive extends ThemeFeature { +class MediaTextInteractive extends AbstractThemeFeature { /** * {@inheritDoc} diff --git a/inc/Modules/Shortcodes/AuthorBio.php b/inc/Modules/Shortcodes/AuthorBio.php index 2f639489..34490374 100644 --- a/inc/Modules/Shortcodes/AuthorBio.php +++ b/inc/Modules/Shortcodes/AuthorBio.php @@ -9,7 +9,7 @@ namespace rtCamp\Theme\Elementary\Modules\Shortcodes; -use rtCamp\Theme\Elementary\Core\ThemeFeature; +use rtCamp\Theme\Elementary\Abstracts\AbstractThemeFeature; use rtCamp\Theme\Elementary\Helpers\Util; /** @@ -24,7 +24,7 @@ * * @since 1.0.0 */ -final class AuthorBio extends ThemeFeature { +final class AuthorBio extends AbstractThemeFeature { /** * {@inheritDoc} From e95b38caaf8aebeb434e6994c2a6fdbf81a0d698 Mon Sep 17 00:00:00 2001 From: pratik-londhe4 Date: Wed, 24 Jun 2026 04:36:07 +0530 Subject: [PATCH 6/8] Fix PHPCS warnings --- inc/Helpers/Util.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/inc/Helpers/Util.php b/inc/Helpers/Util.php index 90bd1c1d..2f90bef6 100644 --- a/inc/Helpers/Util.php +++ b/inc/Helpers/Util.php @@ -173,6 +173,8 @@ private static function encryptor(): Encryption { */ public static function is_feature_enabled( string $flag ): bool { /** + * Shared feature registry. + * * @var FeatureRegistry $features */ $features = Main::get_instance()->get_shared( FeatureRegistry::class ); From 776ca5ec76db40b5952ad75018c4630e5a90f680 Mon Sep 17 00:00:00 2001 From: pratik-londhe4 Date: Wed, 24 Jun 2026 04:49:26 +0530 Subject: [PATCH 7/8] Update testing for the feature flag --- .../MediaTextInteractiveTest.php | 39 ++++--------------- .../inc/Modules/Shortcodes/AuthorBioTest.php | 30 ++------------ 2 files changed, 10 insertions(+), 59 deletions(-) diff --git a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php index 848b2114..93d9d504 100644 --- a/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php +++ b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php @@ -8,8 +8,6 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; -use rtCamp\Theme\Elementary\Core\FeatureRegistry; -use rtCamp\Theme\Elementary\Main; use rtCamp\Theme\Elementary\Modules\BlockExtensions\MediaTextInteractive; use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; @@ -21,41 +19,18 @@ class MediaTextInteractiveTest extends TestCase { /** - * MediaTextInteractive instance. - * - * @var MediaTextInteractive + * MediaTextInteractive implements ConditionallyRegistrable. */ - private MediaTextInteractive $instance; - - /** - * Setup test. - */ - public function set_up(): void { - parent::set_up(); - $this->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() ); - - Main::get_instance()->get_shared( FeatureRegistry::class )->disable( 'media-text-interactive' ); - - $this->assertFalse( $this->instance->can_register() ); + public function test_implements_conditionally_registrable(): void { + $this->assertTrue( is_a( MediaTextInteractive::class, ConditionallyRegistrable::class, true ) ); } /** - * The block render filters are attached by register_hooks(). + * The block render filters are attached when the feature is loaded. */ 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' ] ) ); + $this->assertNotFalse( has_filter( 'render_block_core/button' ) ); + $this->assertNotFalse( has_filter( 'render_block_core/columns' ) ); + $this->assertNotFalse( has_filter( 'render_block_core/video' ) ); } } diff --git a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php index cfa9d883..43418894 100644 --- a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php +++ b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php @@ -8,8 +8,6 @@ declare( strict_types = 1 ); use rtCamp\Theme\Elementary\Tests\TestCase; -use rtCamp\Theme\Elementary\Core\FeatureRegistry; -use rtCamp\Theme\Elementary\Main; use rtCamp\Theme\Elementary\Modules\Shortcodes\AuthorBio; use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; @@ -24,19 +22,10 @@ class AuthorBioTest extends TestCase { /** - * AuthorBio instance. - * - * @var AuthorBio + * AuthorBio implements ConditionallyRegistrable. */ - private AuthorBio $instance; - - /** - * Setup test. - */ - public function set_up(): void { - parent::set_up(); - $this->instance = new AuthorBio(); - $this->instance->register_hooks(); + public function test_implements_conditionally_registrable(): void { + $this->assertTrue( is_a( AuthorBio::class, ConditionallyRegistrable::class, true ) ); } /** @@ -46,19 +35,6 @@ 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() ); - - Main::get_instance()->get_shared( FeatureRegistry::class )->disable( 'author-bio' ); - - $this->assertFalse( $this->instance->can_register() ); - } - /** * It renders the author-bio part for a real user. */ From 89f216a33c181037a3db12b80cb3ad61a8d88b68 Mon Sep 17 00:00:00 2001 From: pratik-londhe4 Date: Wed, 24 Jun 2026 04:57:00 +0530 Subject: [PATCH 8/8] Rename Features Test class --- tests/php/inc/Core/FeaturesTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/php/inc/Core/FeaturesTest.php b/tests/php/inc/Core/FeaturesTest.php index bc434c96..bd8cbebb 100644 --- a/tests/php/inc/Core/FeaturesTest.php +++ b/tests/php/inc/Core/FeaturesTest.php @@ -1,6 +1,6 @@