From 1923782d9b06085745d7208af8351e652dca1e58 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 18 Jun 2026 03:19:51 +0530 Subject: [PATCH 1/4] feat(scaffold): add HMR enable/disable feature HMR (BrowserSync live reload) is now a toggleable feature, gated on a single ENABLE_HMR flag in .env.local that both the build and PHP read: - webpack only starts the BrowserSync server when ENABLE_HMR is not off - Assets.php only enqueues the client under the same flag - the scaffold feature flips ENABLE_HMR in .env.local on enable/disable Default is on, so existing setups are unaffected. browser-sync deps stay installed (they are dev-only), so the toggle is a fast local switch. DISABLE_BS still works for the finer client-only case. Toggle from `npm run init` (manage mode) or by editing .env.local directly. --- .env.local.example | 5 +++++ bin/scaffold.config.js | 14 ++++++++++++++ docs/hmr.md | 18 +++++++++++++++--- inc/Core/Assets.php | 25 ++++++++++++++++++++++++- webpack.config.js | 9 ++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/.env.local.example b/.env.local.example index ffd6ae9d..3a8ad8b9 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,6 +2,11 @@ # LocalWP: yoursite.local, Lando: yoursite.lndo.site, wp-env: localhost, WP Studio: localhost WP_HOST=yoursite.local +# HMR / BrowserSync live reload master switch (default: on). Honoured by both +# the build (BrowserSync server) and PHP (client enqueue). Set to false to fully +# disable live reload. Off values: 0, false, no, off. Toggle from `npm run init`. +ENABLE_HMR=true + # BrowserSync port. Default: 3001. Change if port 3001 is already in use. # Both the build and PHP read this value directly, so changing it here is # enough. Define ELEMENTARY_THEME_BROWSER_SYNC_URL only to override the full diff --git a/bin/scaffold.config.js b/bin/scaffold.config.js index 196d11f4..ef83944e 100644 --- a/bin/scaffold.config.js +++ b/bin/scaffold.config.js @@ -73,6 +73,20 @@ module.exports = { onDisable: ( api ) => api.setDefine( tailwindEntry( api ), tailwindConst( api ), false ), detect: ( api ) => true === api.readDefine( tailwindEntry( api ), tailwindConst( api ) ), }, + { + key: 'hmr', + label: 'HMR (BrowserSync live reload)', + description: 'Live reload in watch mode. Toggling flips ENABLE_HMR in .env.local, which webpack (BrowserSync server) and PHP (client enqueue) both honour. Default on; deps stay installed.', + // No files or deps: the code lives in webpack.config.js + Assets.php + // permanently and is gated on the flag. detect reads the live flag, + // defaulting on when .env.local (gitignored) has no ENABLE_HMR. + onEnable: ( api ) => api.setEnv( '.env.local', 'ENABLE_HMR', 'true' ), + onDisable: ( api ) => api.setEnv( '.env.local', 'ENABLE_HMR', 'false' ), + detect: ( api ) => { + const value = api.readEnv( '.env.local', 'ENABLE_HMR' ); + return null === value || ! [ 'false', '0', 'no', 'off' ].includes( value.toLowerCase() ); + }, + }, ], cleanup: { targets: [ '.github', 'languages' ] }, diff --git a/docs/hmr.md b/docs/hmr.md index 4ff7952a..a9d3e9d8 100644 --- a/docs/hmr.md +++ b/docs/hmr.md @@ -134,15 +134,27 @@ This is required to avoid mixed content errors — the BrowserSync client script ## Advanced -### Disabling BrowserSync +### Enabling / disabling HMR -To disable BrowserSync without removing it from the webpack config, set this in `.env.local`: +HMR (BrowserSync live reload) is controlled by a single master switch in `.env.local`, honoured by both the build (BrowserSync server) and PHP (client enqueue): + +``` +ENABLE_HMR=false +``` + +Default is on — the key only needs setting to turn HMR off. Off values are `0`, `false`, `no`, and `off` (case-insensitive). With it off, `npm start` skips the BrowserSync server entirely and PHP skips the client, so there is no live reload and no console noise from a client pointing at a server that isn't running. The `browser-sync` dev dependencies stay installed, so flipping it back on needs no reinstall. + +You can also toggle it from `npm run init` (manage mode → Toggle features → HMR), which just flips `ENABLE_HMR` in `.env.local` for you. Since `.env.local` is gitignored, this is a per-developer local setting. + +### Disabling the BrowserSync client only + +To keep the BrowserSync server running but stop PHP from enqueuing its client (e.g. when working purely in the block editor), set this in `.env.local`: ``` DISABLE_BS=true ``` -This prevents PHP from enqueuing the BrowserSync client script. The BrowserSync server still starts (webpack still runs it), but the browser won't connect to it. Useful when working purely in the block editor and you don't want the BrowserSync client loading on the frontend. +This prevents PHP from enqueuing the BrowserSync client script. The BrowserSync server still starts (webpack still runs it), but the browser won't connect to it. For a full off switch (server included), use `ENABLE_HMR=false` above. ### Overriding the BrowserSync client URL diff --git a/inc/Core/Assets.php b/inc/Core/Assets.php index 9774e19d..98b5375d 100644 --- a/inc/Core/Assets.php +++ b/inc/Core/Assets.php @@ -144,7 +144,7 @@ public function enqueue_assets(): void { * @action wp_enqueue_scripts */ public function enqueue_browser_sync(): void { - if ( 'local' !== wp_get_environment_type() || $this->is_browser_sync_disabled() ) { + if ( 'local' !== wp_get_environment_type() || ! $this->is_hmr_enabled() || $this->is_browser_sync_disabled() ) { return; } @@ -188,6 +188,29 @@ private function get_browser_sync_port(): int { return $default; } + /** + * Whether HMR (BrowserSync live reload) is enabled via ENABLE_HMR in .env.local. + * + * Master switch for both sides: webpack only starts the BrowserSync server, + * and PHP only enqueues its client, when this is on. Defaults ON when the key + * is absent. Off values are `0`, `false`, `no`, and `off` (case-insensitive). + * DISABLE_BS still works as a finer client-only override. Toggle it from + * `npm run init` (manage mode) or by editing .env.local directly. + * + * THIS METHOD IS INTENDED FOR LOCAL DEVELOPMENT ENVIRONMENTS ONLY. + * + * @return bool True when HMR is enabled. + */ + private function is_hmr_enabled(): bool { + $value = $this->get_env_value( 'ENABLE_HMR' ); + + if ( null === $value ) { + return true; + } + + return ! in_array( strtolower( $value ), [ '0', 'false', 'no', 'off' ], true ); + } + /** * Whether BrowserSync is disabled via DISABLE_BS in .env.local. * diff --git a/webpack.config.js b/webpack.config.js index 340ef06a..711802ca 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -38,6 +38,13 @@ if ( isWatch ) { require( 'dotenv' ).config( { path: '.env.local', quiet: true } ); } +// HMR (BrowserSync) master switch read from .env.local (ENABLE_HMR), defaulting +// on; only an explicit off value disables it. Mirrors is_hmr_enabled() in +// inc/Core/Assets.php so one flag controls both the BrowserSync server (here) +// and its client enqueue (PHP). +const hmrFlag = String( process.env.ENABLE_HMR || '' ).toLowerCase(); +const isHmrEnabled = ! [ 'false', '0', 'no', 'off' ].includes( hmrFlag ); + const DEFAULT_BS_PORT = 3001; /** @@ -471,7 +478,7 @@ const getCopyPlugin = () => * @return {Array} BrowserSync plugin instances. */ const getBrowserSyncPlugins = () => { - if ( ! isWatch ) { + if ( ! isWatch || ! isHmrEnabled ) { return []; } From 91be41fcfc90595e8568b0e4371ad14122d34ebf Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 23 Jun 2026 19:21:08 +0530 Subject: [PATCH 2/4] test(hmr): cover ENABLE_HMR master switch and DISABLE_BS override Mirror the plugin skeleton's AssetsHmrTest so the theme's HMR feature has the same coverage. Exercises the two private env-flag helpers in Assets via a fresh instance per case (each re-reads .env.local): - is_hmr_enabled(): defaults on when ENABLE_HMR is absent, off for 0/false/no/off (case-insensitive), on for 1/true/yes/on - is_browser_sync_disabled(): independent of HMR; off by default, on for truthy DISABLE_BS Backs up and restores any real .env.local so the developer's file is left untouched. --- tests/php/inc/Core/AssetsHmrTest.php | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/php/inc/Core/AssetsHmrTest.php diff --git a/tests/php/inc/Core/AssetsHmrTest.php b/tests/php/inc/Core/AssetsHmrTest.php new file mode 100644 index 00000000..2d78af48 --- /dev/null +++ b/tests/php/inc/Core/AssetsHmrTest.php @@ -0,0 +1,132 @@ +env_file = trailingslashit( ELEMENTARY_THEME_PATH ) . '.env.local'; + $this->env_backup = is_readable( $this->env_file ) ? file_get_contents( $this->env_file ) : null; + $this->clear_env(); + } + + /** + * Restore the developer's .env.local exactly as it was. + */ + public function tear_down(): void { + $this->clear_env(); + + if ( null !== $this->env_backup ) { + file_put_contents( $this->env_file, $this->env_backup ); + } + + parent::tear_down(); + } + + /** + * Remove the test .env.local if present. + */ + private function clear_env(): void { + if ( file_exists( $this->env_file ) ) { + unlink( $this->env_file ); + } + } + + /** + * Write a single KEY=value line to .env.local. + * + * @param string $key Variable name. + * @param string $value Value. + */ + private function write_env( string $key, string $value ): void { + file_put_contents( $this->env_file, "{$key}={$value}\n" ); + } + + /** + * Invoke a private Assets method on a fresh instance (which re-reads .env.local). + * + * @param string $method Method name. + * + * @return mixed Return value. + */ + private function invoke( string $method ) { + $ref = new \ReflectionMethod( Assets::class, $method ); + $ref->setAccessible( true ); + + return $ref->invoke( new Assets() ); + } + + /** + * HMR is on when ENABLE_HMR is absent. + */ + public function test_hmr_enabled_by_default(): void { + $this->assertTrue( $this->invoke( 'is_hmr_enabled' ) ); + } + + /** + * Off values switch HMR off. + */ + public function test_hmr_disabled_for_off_values(): void { + foreach ( [ 'false', '0', 'no', 'off', 'OFF' ] as $value ) { + $this->write_env( 'ENABLE_HMR', $value ); + $this->assertFalse( $this->invoke( 'is_hmr_enabled' ), "ENABLE_HMR={$value}" ); + } + } + + /** + * Anything else keeps HMR on. + */ + public function test_hmr_enabled_for_on_values(): void { + foreach ( [ 'true', '1', 'yes', 'on' ] as $value ) { + $this->write_env( 'ENABLE_HMR', $value ); + $this->assertTrue( $this->invoke( 'is_hmr_enabled' ), "ENABLE_HMR={$value}" ); + } + } + + /** + * DISABLE_BS is independent of HMR: off by default, on for truthy values. + */ + public function test_disable_bs_is_independent(): void { + $this->assertFalse( $this->invoke( 'is_browser_sync_disabled' ) ); + + $this->write_env( 'DISABLE_BS', 'true' ); + $this->assertTrue( $this->invoke( 'is_browser_sync_disabled' ) ); + } +} From d8d4a13d20b3cd7803da46594d5ce13dd1c7b431 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 24 Jun 2026 08:01:41 +0530 Subject: [PATCH 3/4] test(hmr): isolate env reads from the real .env.local --- inc/Core/Assets.php | 15 ++++++- tests/php/inc/Core/AssetsHmrTest.php | 66 ++++++++++++++++++---------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/inc/Core/Assets.php b/inc/Core/Assets.php index 98b5375d..c7a90684 100644 --- a/inc/Core/Assets.php +++ b/inc/Core/Assets.php @@ -246,7 +246,7 @@ private function is_browser_sync_disabled(): bool { * @return string|null The value, or null when not found. */ private function get_env_value( string $key ): ?string { - $env_file = $this->base_dir . '.env.local'; + $env_file = $this->env_file_path(); if ( ! is_readable( $env_file ) ) { return null; @@ -265,4 +265,17 @@ private function get_env_value( string $key ): ?string { return null; } + + /** + * Absolute path to the .env.local file read for local-dev flags. + * + * Isolated into its own method so tests can point the env lookup at a + * throwaway temp file (via a subclass) instead of touching the developer's + * real .env.local. + * + * @return string Absolute path to .env.local in the theme root. + */ + protected function env_file_path(): string { + return $this->base_dir . '.env.local'; + } } diff --git a/tests/php/inc/Core/AssetsHmrTest.php b/tests/php/inc/Core/AssetsHmrTest.php index 2d78af48..fe93290f 100644 --- a/tests/php/inc/Core/AssetsHmrTest.php +++ b/tests/php/inc/Core/AssetsHmrTest.php @@ -15,53 +15,43 @@ * * Covers is_hmr_enabled() (the ENABLE_HMR master switch) and * is_browser_sync_disabled() (the independent DISABLE_BS override). Both read - * .env.local in the theme root, so each test writes a fresh file and a new - * Assets instance re-reads it. + * the env file resolved by Assets::env_file_path(); the suite overrides that + * seam to a throwaway temp file (one per test), so it never touches — or + * depends on — the developer's real .env.local. * * @since 1.0.0 */ class AssetsHmrTest extends TestCase { /** - * Absolute path to the theme's .env.local. + * Absolute path to this test's throwaway env file. * * @var string */ private string $env_file = ''; /** - * Contents of a pre-existing .env.local, restored after the test. - * - * @var string|null - */ - private ?string $env_backup = null; - - /** - * Back up any real .env.local and start each test from a clean slate. + * Point each test at its own temp env file, starting from a clean slate. */ public function set_up(): void { parent::set_up(); - $this->env_file = trailingslashit( ELEMENTARY_THEME_PATH ) . '.env.local'; - $this->env_backup = is_readable( $this->env_file ) ? file_get_contents( $this->env_file ) : null; + // Unique per test (parallel-safe); start with no file so defaults apply. + $this->env_file = tempnam( sys_get_temp_dir(), 'elementary-hmr-env-' ); $this->clear_env(); } /** - * Restore the developer's .env.local exactly as it was. + * Remove the temp env file. */ public function tear_down(): void { $this->clear_env(); - if ( null !== $this->env_backup ) { - file_put_contents( $this->env_file, $this->env_backup ); - } - parent::tear_down(); } /** - * Remove the test .env.local if present. + * Delete the temp env file if present. */ private function clear_env(): void { if ( file_exists( $this->env_file ) ) { @@ -70,7 +60,7 @@ private function clear_env(): void { } /** - * Write a single KEY=value line to .env.local. + * Write a single KEY=value line to the temp env file. * * @param string $key Variable name. * @param string $value Value. @@ -80,17 +70,47 @@ private function write_env( string $key, string $value ): void { } /** - * Invoke a private Assets method on a fresh instance (which re-reads .env.local). + * Invoke a private Assets method on a fresh instance whose env-file lookup + * is redirected to this test's temp file. * * @param string $method Method name. * * @return mixed Return value. */ private function invoke( string $method ) { + $assets = new class( $this->env_file ) extends Assets { + /** + * Path returned by env_file_path(). + * + * @var string + */ + private string $test_env_file; + + /** + * Capture the throwaway env file, then build a normal Assets. + * + * @param string $env_file Throwaway env file to read instead of .env.local. + */ + public function __construct( string $env_file ) { + $this->test_env_file = $env_file; + parent::__construct(); + } + + /** + * Redirect the env lookup to the test's temp file. + * + * @return string + */ + protected function env_file_path(): string { + return $this->test_env_file; + } + }; + + // Private parent methods are invocable on the subclass instance; + // setAccessible() is a no-op (and deprecated) on PHP 8.1+, so it is omitted. $ref = new \ReflectionMethod( Assets::class, $method ); - $ref->setAccessible( true ); - return $ref->invoke( new Assets() ); + return $ref->invoke( $assets ); } /** From 267d714946f14ecc964cd36ad00695018bc531c2 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Wed, 24 Jun 2026 10:48:10 +0530 Subject: [PATCH 4/4] feat(scaffold): mark example sets for selective removal (#720) Mark the demo modules (media-text block extension, theme options, author bio), example components and page-creation pattern with grouped wp:example markers so init can drop selected sets while keeping the rest. The three Main.php modules use keyed markers to scope their shared regions. --- bin/scaffold.config.js | 41 +++++++++++++++++++++++++++++++++++++++++ inc/Main.php | 16 +++++++++++++++- phpcs.xml.dist | 7 +++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/bin/scaffold.config.js b/bin/scaffold.config.js index ef83944e..5020dce7 100644 --- a/bin/scaffold.config.js +++ b/bin/scaffold.config.js @@ -89,6 +89,47 @@ module.exports = { }, ], + // First-run "which example sets to remove?" prompt. The three module groups + // share inc/Main.php, so each scopes its own region with a keyed marker + // (wp:example:); components and patterns are delete-only (auto-discovered, + // nothing to strip). Markers are stripped either way; code/files drop on remove. + examples: { + marker: 'wp:example', + groups: [ + { + key: 'block-extension', + label: 'Media-text block extension', + marker: 'wp:example:block-extension', + strip: [ 'inc/Main.php' ], + remove: [ 'inc/Modules/BlockExtensions', 'patterns/media-text-interactive.php', 'src/js/frontend/modules/media-text.js' ], + }, + { + key: 'settings', + label: 'Theme options settings page', + marker: 'wp:example:settings', + strip: [ 'inc/Main.php' ], + remove: [ 'inc/Modules/Settings' ], + }, + { + key: 'shortcode', + label: 'Author bio shortcode', + marker: 'wp:example:shortcode', + strip: [ 'inc/Main.php' ], + remove: [ 'inc/Modules/Shortcodes', 'tests/php/inc/Modules/Shortcodes' ], + }, + { + key: 'components', + label: 'Example components (button, card)', + remove: [ 'src/components/button', 'src/components/card' ], + }, + { + key: 'patterns', + label: 'Page-creation pattern', + remove: [ 'patterns/page-creation-pattern.php' ], + }, + ], + }, + cleanup: { targets: [ '.github', 'languages' ] }, docsUrl: 'https://github.com/rtCamp/theme-elementary/blob/main/README.md', diff --git a/inc/Main.php b/inc/Main.php index 350d778e..6d74823b 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -11,7 +11,15 @@ 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}; +// wp:example:block-extension +use rtCamp\Theme\Elementary\Modules\BlockExtensions\MediaTextInteractive; +// wp:example:block-extension:end +// wp:example:settings +use rtCamp\Theme\Elementary\Modules\Settings\ThemeOptions; +// wp:example:settings:end +// wp:example:shortcode +use rtCamp\Theme\Elementary\Modules\Shortcodes\AuthorBio; +// wp:example:shortcode:end /** * Class Main @@ -33,9 +41,15 @@ class Main { Components::class, Templates::class, Encryption::class, + // wp:example:block-extension MediaTextInteractive::class, + // wp:example:block-extension:end + // wp:example:settings ThemeOptions::class, + // wp:example:settings:end + // wp:example:shortcode AuthorBio::class, + // wp:example:shortcode:end ]; /** diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 2b91d161..91253859 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -108,6 +108,13 @@ tests/* + + + inc/Main.php + + tests/*