From 8e77efef8ecc0bb7d1346351f15d3cf697f052f8 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 18 Jun 2026 04:00:45 +0530 Subject: [PATCH 1/5] feat: wire the Tailwind theme-token webpack plugin when present Gates GenerateTailwindThemePlugin (from @rtcamp/wp-tooling) on the entry file src/css/frontend/tailwind.css, guard-requiring wp-tooling so the build still works when it is absent. Dormant until the Tailwind feature creates the entry. --- webpack.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/webpack.config.js b/webpack.config.js index e25983e0..d895e828 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,20 @@ const webpack = require( 'webpack' ); const rtlcss = require( 'rtlcss' ); const { optimize: svgoOptimize } = require( 'svgo' ); +/** + * Tailwind (opt-in). Wired only when the entry file exists — the same gate + * functions.php uses to enqueue the compiled stylesheet. + */ +const tailwindEntry = path.resolve( process.cwd(), 'src', 'css', 'frontend', 'tailwind.css' ); +let GenerateTailwindThemePlugin = null; +if ( fs.existsSync( tailwindEntry ) ) { + try { + ( { GenerateTailwindThemePlugin } = require( '@rtcamp/wp-tooling/tailwind-config' ) ); + } catch ( err ) { + // @rtcamp/wp-tooling not installed; Tailwind stays off. + } +} + const isHot = process.argv.includes( '--hot' ); const isWatch = process.argv.includes( '--watch' ) || process.argv.includes( 'watch' ) || isHot; @@ -546,6 +560,7 @@ const styles = { ...sharedNonHotConfig.plugins.filter( isNotOneOfPlugins( STYLE_ONLY_IGNORED_PLUGINS ), ), + ...( GenerateTailwindThemePlugin ? [ new GenerateTailwindThemePlugin() ] : [] ), new CssAssetRtlPlugin(), ], }; From 41daab136bd36dc144d857c42166a05cb84fbb6f Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 18 Jun 2026 04:00:46 +0530 Subject: [PATCH 2/5] feat(scaffold): add Tailwind CSS opt-in feature Toggleable in manage mode: copies the entry CSS + PostCSS config and adds the tailwindcss / @tailwindcss/postcss devDeps; detected via the entry file. The build side lives in webpack.config.js. --- bin/features/tailwind/postcss.config.js | 1 + bin/features/tailwind/tailwind.css | 6 +++++ bin/scaffold.config.js | 32 +++++++++++++++++++++++-- functions.php | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 bin/features/tailwind/postcss.config.js create mode 100644 bin/features/tailwind/tailwind.css diff --git a/bin/features/tailwind/postcss.config.js b/bin/features/tailwind/postcss.config.js new file mode 100644 index 00000000..973f5098 --- /dev/null +++ b/bin/features/tailwind/postcss.config.js @@ -0,0 +1 @@ +module.exports = require( '@rtcamp/wp-tooling/tailwind-config/postcss' ); diff --git a/bin/features/tailwind/tailwind.css b/bin/features/tailwind/tailwind.css new file mode 100644 index 00000000..709a714d --- /dev/null +++ b/bin/features/tailwind/tailwind.css @@ -0,0 +1,6 @@ +/* Tailwind CSS entry — edit freely. WordPress preset tokens live in _tailwind-theme.css. */ +@source "../../../"; +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); +@import "./_tailwind-theme.css"; diff --git a/bin/scaffold.config.js b/bin/scaffold.config.js index af6a2c5e..694bacfe 100644 --- a/bin/scaffold.config.js +++ b/bin/scaffold.config.js @@ -6,6 +6,12 @@ * search-replaces files under bin/. */ +// functions.php and the theme's Tailwind enable constant (derived from the +// resolved identity). functions.php defines it false by default; the feature +// flips it, and Assets.php enqueues off it. +const tailwindEntry = () => 'functions.php'; +const tailwindConst = ( api ) => `${ api.identity.constantPrefix }_ENABLE_TAILWIND`; + module.exports = { kind: 'theme', vendor: 'rtcamp', @@ -42,9 +48,31 @@ module.exports = { steps: { composer: true, cleanup: true, git: true, husky: true }, - // Optional features toggled in manage mode (none yet). + // Optional features toggled in manage mode. Tailwind enqueue is gated on the + // ELEMENTARY_THEME_ENABLE_TAILWIND constant in functions.php; the feature flips + // it and adds/removes the entry CSS, PostCSS config and deps. webpack still + // gates the theme.json token plugin on the entry file at build time. featuresDir: 'bin/features', - features: [], + features: [ + { + key: 'tailwind', + label: 'Tailwind CSS', + description: 'Tailwind v4 (opt-in). Adds the entry CSS, PostCSS config and deps, and flips the ENABLE_TAILWIND constant that gates the enqueue.', + apply: { + files: [ + { from: 'tailwind/tailwind.css', to: 'src/css/frontend/tailwind.css' }, + { from: 'tailwind/postcss.config.js', to: 'postcss.config.js' }, + ], + devDependencies: { + tailwindcss: '^4.3.0', + '@tailwindcss/postcss': '^4.3.0', + }, + }, + onEnable: ( api ) => api.setDefine( tailwindEntry( api ), tailwindConst( api ), true ), + onDisable: ( api ) => api.setDefine( tailwindEntry( api ), tailwindConst( api ), false ), + detect: ( api ) => true === api.readDefine( tailwindEntry( api ), tailwindConst( api ) ), + }, + ], cleanup: { targets: [ '.github', 'languages' ] }, diff --git a/functions.php b/functions.php index 2e1a2a07..9a048384 100644 --- a/functions.php +++ b/functions.php @@ -35,7 +35,7 @@ function constants(): void { } if ( ! defined( 'ELEMENTARY_THEME_ENABLE_TAILWIND' ) ) { - define( 'ELEMENTARY_THEME_ENABLE_TAILWIND', file_exists( get_template_directory() . '/src/css/frontend/tailwind.css' ) ); + define( 'ELEMENTARY_THEME_ENABLE_TAILWIND', false ); } } From 0cf93d65b67225ab842567592c21647c8720f06b Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 18 Jun 2026 18:15:29 +0530 Subject: [PATCH 3/5] docs(tailwind): fix stale default-enablement comments functions.php now defaults ELEMENTARY_THEME_ENABLE_TAILWIND to false (the scaffold feature flips it); the webpack token plugin is still entry-file gated at build time, and the wp-tooling catch only skips that plugin, not Tailwind compilation. --- inc/Core/Assets.php | 4 ++-- webpack.config.js | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/inc/Core/Assets.php b/inc/Core/Assets.php index 87131170..bc4f0146 100644 --- a/inc/Core/Assets.php +++ b/inc/Core/Assets.php @@ -27,8 +27,8 @@ class Assets extends AssetLoader implements Registrable, Shareable { /** * Whether Tailwind CSS is enabled for this theme. * - * Tailwind support is opt-in. It defaults to true when - * src/css/frontend/tailwind.css exists (generated by GenerateTailwindThemePlugin). + * Off by default. The scaffold's Tailwind feature flips + * ELEMENTARY_THEME_ENABLE_TAILWIND to true in functions.php when enabled. * * To force-enable or disable before the theme loads, define the constant in * wp-config.php or a must-use plugin: diff --git a/webpack.config.js b/webpack.config.js index d895e828..148784db 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,8 +11,9 @@ const rtlcss = require( 'rtlcss' ); const { optimize: svgoOptimize } = require( 'svgo' ); /** - * Tailwind (opt-in). Wired only when the entry file exists — the same gate - * functions.php uses to enqueue the compiled stylesheet. + * Tailwind (opt-in). The theme.json token plugin is wired only when the entry + * file exists; the build skips it otherwise. Runtime enqueue is gated separately + * on ELEMENTARY_THEME_ENABLE_TAILWIND (see functions.php / Assets.php). */ const tailwindEntry = path.resolve( process.cwd(), 'src', 'css', 'frontend', 'tailwind.css' ); let GenerateTailwindThemePlugin = null; @@ -20,7 +21,8 @@ if ( fs.existsSync( tailwindEntry ) ) { try { ( { GenerateTailwindThemePlugin } = require( '@rtcamp/wp-tooling/tailwind-config' ) ); } catch ( err ) { - // @rtcamp/wp-tooling not installed; Tailwind stays off. + // @rtcamp/wp-tooling not installed; skip the theme.json token plugin + // (Tailwind utilities still compile via PostCSS). } } From 5a4277058032405521eace90fdbd67343079416c Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Thu, 18 Jun 2026 03:19:51 +0530 Subject: [PATCH 4/5] 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 694bacfe..eca11862 100644 --- a/bin/scaffold.config.js +++ b/bin/scaffold.config.js @@ -72,6 +72,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 bc4f0146..253b5b63 100644 --- a/inc/Core/Assets.php +++ b/inc/Core/Assets.php @@ -140,7 +140,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; } @@ -184,6 +184,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 148784db..4cd68123 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 cff8a78a90425788baad54fa57198225d92745f7 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Fri, 19 Jun 2026 11:51:20 +0530 Subject: [PATCH 5/5] chore(scaffold): run init via @rtcamp/wp-tooling/init Resolve the init engine from the @rtcamp/wp-tooling npm dependency instead of probing vendor/rtcamp/wp-framework. Drops the Composer vendor path and the fs existence check. --- bin/init.js | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/bin/init.js b/bin/init.js index 33b9e62d..ab57b628 100755 --- a/bin/init.js +++ b/bin/init.js @@ -3,16 +3,15 @@ /* eslint no-console: 0 */ /** - * Theme setup; thin wrapper that delegates to the shared scaffold engine from - * rtcamp/wp-framework, passing this theme's bin/scaffold.config.js. + * Theme setup; thin wrapper that delegates to the shared init engine in + * @rtcamp/wp-tooling, passing this theme's bin/scaffold.config.js. * - * Requires `composer install` and `npm install`. Invoke with `npm run init`. + * Requires `npm install`. Invoke with `npm run init`. */ /** * External dependencies */ -const fs = require( 'fs' ); const path = require( 'path' ); /** @@ -20,26 +19,17 @@ const path = require( 'path' ); */ const config = require( './scaffold.config' ); -const root = path.resolve( __dirname, '..' ); -const enginePath = path.join( root, 'vendor', 'rtcamp', 'wp-framework', 'bin', 'scaffold.js' ); - -if ( ! fs.existsSync( enginePath ) ) { - console.error( '\nScaffold engine not found at vendor/rtcamp/wp-framework.' ); - console.error( 'Run `composer install` first, then `npm run init`.\n' ); - process.exit( 1 ); -} - let run; try { - ( { run } = require( enginePath ) ); + ( { run } = require( '@rtcamp/wp-tooling/init' ) ); } catch ( err ) { - console.error( '\nCould not load the scaffold engine.' ); - console.error( 'Ensure dependencies are installed (`composer install` && `npm install`).\n' ); + console.error( '\nCould not load the init engine from @rtcamp/wp-tooling.' ); + console.error( 'Ensure dependencies are installed (`npm install`).\n' ); console.error( err.message ); process.exit( 1 ); } -run( config, { root } ) +run( config, { root: path.resolve( __dirname, '..' ) } ) .then( () => process.exit( process.exitCode || 0 ) ) .catch( ( err ) => { console.error( err );