diff --git a/composer.json b/composer.json index 1dc24de5..775b8d2f 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 fa4da254..e01d0c11 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": "db06e940e3f311fbccbb71fc2828d672", + "content-hash": "2cb40a71ad05e3074aa7f27c09214369", "packages": [ { "name": "rtcamp/wp-framework", @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/rtCamp/wp-framework.git", - "reference": "fa9a2b8feecc73cd484740c7d5acc8ff3dfb9bf5" + "reference": "e30352cf578fcba49f889f801311b881cc368908" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/fa9a2b8feecc73cd484740c7d5acc8ff3dfb9bf5", - "reference": "fa9a2b8feecc73cd484740c7d5acc8ff3dfb9bf5", + "url": "https://api.github.com/repos/rtCamp/wp-framework/zipball/e30352cf578fcba49f889f801311b881cc368908", + "reference": "e30352cf578fcba49f889f801311b881cc368908", "shasum": "" }, "require": { @@ -75,7 +75,7 @@ "issues": "https://github.com/rtCamp/wp-framework/issues", "source": "https://github.com/rtCamp/wp-framework" }, - "time": "2026-06-19T12:37:08+00:00" + "time": "2026-06-23T22:36:13+00:00" } ], "packages-dev": [ @@ -3206,12 +3206,12 @@ "source": { "type": "git", "url": "https://github.com/wp-cli/wp-cli.git", - "reference": "49ad9e440b86ed46da49cc2062970ade9c4d798f" + "reference": "fc140030b5c1a47020812b74e2b8d767d7242725" }, "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/fc140030b5c1a47020812b74e2b8d767d7242725", + "reference": "fc140030b5c1a47020812b74e2b8d767d7242725", "shasum": "" }, "require": { @@ -3282,7 +3282,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-18T15:36:01+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -3457,6 +3457,9 @@ "platform": { "php": ">=8.2" }, - "platform-dev": [], + "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, "plugin-api-version": "2.6.0" } diff --git a/inc/Abstracts/AbstractThemeFeature.php b/inc/Abstracts/AbstractThemeFeature.php new file mode 100644 index 00000000..0c07df4b --- /dev/null +++ b/inc/Abstracts/AbstractThemeFeature.php @@ -0,0 +1,33 @@ +get_shared( FeatureRegistry::class ); + } +} 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/Helpers/Util.php b/inc/Helpers/Util.php index 2cc66995..2f90bef6 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\FeatureRegistry; 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. + * + * 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. 'author-bio'. + * + * @return bool True if enabled, false otherwise. + */ + public static function is_feature_enabled( string $flag ): bool { + /** + * Shared feature registry. + * + * @var FeatureRegistry $features + */ + $features = Main::get_instance()->get_shared( FeatureRegistry::class ); + + return $features->is_enabled( $flag ); + } } diff --git a/inc/Main.php b/inc/Main.php index 350d778e..580997db 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, FeatureRegistry, 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, + FeatureRegistry::class, MediaTextInteractive::class, ThemeOptions::class, + FeaturesSettingsPage::class, AuthorBio::class, ]; @@ -42,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 49a58382..00068bd7 100644 --- a/inc/Modules/BlockExtensions/MediaTextInteractive.php +++ b/inc/Modules/BlockExtensions/MediaTextInteractive.php @@ -10,15 +10,33 @@ namespace rtCamp\Theme\Elementary\Modules\BlockExtensions; use WP_HTML_Tag_Processor; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; +use rtCamp\Theme\Elementary\Abstracts\AbstractThemeFeature; /** * 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 extends AbstractThemeFeature { + + /** + * {@inheritDoc} + */ + protected function get_slug(): string { + return 'media-text-interactive'; + } /** - * Register hooks. + * {@inheritDoc} + */ + 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' ); + } + + /** + * {@inheritDoc} */ public function register_hooks(): void { add_filter( 'render_block_core/button', [ $this, 'render_block_core_button' ], 10, 2 ); @@ -60,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 ), @@ -93,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 new file mode 100644 index 00000000..75af23e7 --- /dev/null +++ b/inc/Modules/Settings/FeaturesSettingsPage.php @@ -0,0 +1,48 @@ +get_shared( FeatureRegistry::class ); + } + + /** + * {@inheritDoc} + */ + protected function get_page_title(): string { + return __( 'Elementary Features', 'elementary-theme' ); + } + + /** + * {@inheritDoc} + */ + protected function get_menu_title(): string { + return __( 'Features', 'elementary-theme' ); + } +} diff --git a/inc/Modules/Shortcodes/AuthorBio.php b/inc/Modules/Shortcodes/AuthorBio.php index cbb96a3f..34490374 100644 --- a/inc/Modules/Shortcodes/AuthorBio.php +++ b/inc/Modules/Shortcodes/AuthorBio.php @@ -9,23 +9,39 @@ namespace rtCamp\Theme\Elementary\Modules\Shortcodes; +use rtCamp\Theme\Elementary\Abstracts\AbstractThemeFeature; use rtCamp\Theme\Elementary\Helpers\Util; -use rtCamp\WPFramework\Contracts\Interfaces\Registrable; /** * 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 + * registration is decided once at load. * * @since 1.0.0 */ -final class AuthorBio implements Registrable { +final class AuthorBio extends AbstractThemeFeature { + + /** + * {@inheritDoc} + */ + protected function get_slug(): string { + return 'author-bio'; + } + + /** + * {@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' ); + } /** - * Register hooks. + * {@inheritDoc} * * @since 1.0.0 */ diff --git a/tests/php/inc/Core/FeaturesTest.php b/tests/php/inc/Core/FeaturesTest.php new file mode 100644 index 00000000..bd8cbebb --- /dev/null +++ b/tests/php/inc/Core/FeaturesTest.php @@ -0,0 +1,125 @@ +assertInstanceOf( FeatureSelector::class, $registry ); + $this->assertInstanceOf( Shareable::class, $registry ); + } + + /** + * It is registered in Main before the feature classes that depend on it. + */ + public function test_registered_and_shared_in_main(): void { + $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 FeatureRegistry() )->get_context() ); + } + + /** + * 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 = Main::get_instance()->get_shared( FeatureRegistry::class )->get_registered(); + + $this->assertContains( 'author-bio', $registered ); + $this->assertContains( 'media-text-interactive', $registered ); + } + + /** + * Shared option key and override constants derive from the context. + */ + public function test_key_derivation(): void { + $registry = new FeatureRegistry(); + + $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 { + $registry = Main::get_instance()->get_shared( FeatureRegistry::class ); + + $this->assertTrue( $registry->is_enabled( 'author-bio' ) ); + + $registry->disable( 'author-bio' ); + $this->assertFalse( $registry->is_enabled( 'author-bio' ) ); + + $registry->enable( 'author-bio' ); + $this->assertTrue( $registry->is_enabled( 'author-bio' ) ); + } + + /** + * All flags share a single option row; there are no per-flag option rows. + */ + public function test_flags_share_one_option_row(): void { + $registry = Main::get_instance()->get_shared( FeatureRegistry::class ); + + $registry->enable( 'author-bio' ); + $registry->disable( 'media-text-interactive' ); + + $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 on construction with non-empty labels. + */ + public function test_get_features_provides_labels(): void { + 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." ); + $this->assertNotSame( '', $meta['description'] ); + } + } + + /** + * Util::is_feature_enabled() proxies the shared registry instance. + */ + public function test_util_helper_reads_flags(): void { + $this->assertTrue( Util::is_feature_enabled( 'author-bio' ) ); + + Main::get_instance()->get_shared( FeatureRegistry::class )->disable( '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 new file mode 100644 index 00000000..93d9d504 --- /dev/null +++ b/tests/php/inc/Modules/BlockExtensions/MediaTextInteractiveTest.php @@ -0,0 +1,36 @@ +assertTrue( is_a( MediaTextInteractive::class, ConditionallyRegistrable::class, true ) ); + } + + /** + * The block render filters are attached when the feature is loaded. + */ + public function test_registers_block_render_filters(): void { + $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/Settings/FeaturesSettingsPageTest.php b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php new file mode 100644 index 00000000..e279ec1d --- /dev/null +++ b/tests/php/inc/Modules/Settings/FeaturesSettingsPageTest.php @@ -0,0 +1,56 @@ +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 FeatureRegistry (context 'elementary'), + * so it registers the single shared option `elementary_features`. + */ + public function test_page_is_driven_by_theme_features(): void { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + + $page = new FeaturesSettingsPage(); + $page->register_settings(); + + $registered = get_registered_settings(); + + $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 d4470188..43418894 100644 --- a/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php +++ b/tests/php/inc/Modules/Shortcodes/AuthorBioTest.php @@ -9,6 +9,7 @@ use rtCamp\Theme\Elementary\Tests\TestCase; use rtCamp\Theme\Elementary\Modules\Shortcodes\AuthorBio; +use rtCamp\WPFramework\Contracts\Interfaces\ConditionallyRegistrable; /** * Class AuthorBioTest @@ -21,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 ) ); } /**