diff --git a/config/plugins.js b/config/plugins.js index dd5858ee..78328967 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -12,6 +12,7 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl const WebpackImageSizesPlugin = require('./webpack-image-sizes-plugin') const WebpackThemeJsonPlugin = require('./webpack-theme-json-plugin') const SpriteHashPlugin = require('./webpack-sprite-hash-plugin') +const WebpackBrowserSyncPlugin = require('./webpack-browser-sync-plugin') module.exports = { get: function (mode) { @@ -75,6 +76,7 @@ module.exports = { filename: '[name].css', }) ) + plugins.push(...WebpackBrowserSyncPlugin.getPlugins(mode)) } return plugins diff --git a/config/webpack-browser-sync-plugin.js b/config/webpack-browser-sync-plugin.js new file mode 100644 index 00000000..90d6c23a --- /dev/null +++ b/config/webpack-browser-sync-plugin.js @@ -0,0 +1,303 @@ +const fs = require('fs') +const path = require('path') +const chalk = require('chalk') +const webpack = require('webpack') +const BrowserSyncPlugin = require('browser-sync-webpack-plugin') + +const logId = '[' + chalk.blue('WebpackBrowserSyncPlugin') + ']' + +/** + * Dev-only: BrowserSync + HMR + * + * To use BrowserSync (BS) or Hot Module Replacement (HMR), you must define the following environment variables. + * Two modes are available: + * - BROWSER_SYNC_APP_URL + * - BROWSER_SYNC_APP_PORT + * - BROWSER_SYNC_APP_IP + * + * "host" mode + * Enables live reloading in your local environment by using the domain name to configure the proxy. + * + * "ip" mode + * Uses an IP address for the proxy. + * By entering your local IP, you can access and preview your environment from another device (such as a mobile phone) on the same network. + * + * Use yarn scripts to start the development server: `yarn start:host` | `yarn start:ip`. + */ +class WebpackBrowserSyncPlugin { + /** + * @param {'development'|'production'|string} mode Webpack mode from `plugins.get(mode)`. + * @returns {import('webpack').WebpackPluginInstance[]} Empty in production. + */ + static getPlugins(mode) { + if (mode === 'production') { + return [] + } + + const hotReload = this.resolveMode() + + if (hotReload === '') { + return [] + } + + const env = this.getLocalAppEnv() + const issues = this.collectBrowserSyncConfigIssues(hotReload, env) + if (issues.missing.length > 0) { + this.fatalConfig(hotReload, issues) + } + + const parsed = this.parseAppConnection(env) + if (!parsed) { + this.fatalConfig(hotReload, { missing: ['BROWSER_SYNC_APP_URL'] }) + } + + const { appUrl, urlHadPort, wordPressPort, localAppPortStr } = parsed + + const browserSyncPort = this.pickBrowserSyncPort(wordPressPort, localAppPortStr, urlHadPort) + + let appHost = appUrl.replace(/^https?:\/\//i, '').replace(/:\d+$/, '') + let appIp = String(env.BROWSER_SYNC_APP_IP || '') + .trim() + .replace(/:\d+$/, '') + + /** @type {import('browser-sync').Options} */ + const browserSyncConfig = { + injectCss: true, + proxy: appUrl, + port: browserSyncPort, + files: ['**/*.php', 'dist/images/**/*', 'dist/icons/**/*', 'dist/fonts/**/*', 'dist/**/*.css', 'dist/**/*.js'], + open: false, + reloadDelay: 0, + notify: true, + injectNotification: true, + } + + if (hotReload === 'host') { + Object.assign(browserSyncConfig, { host: appHost }) + } + + if (hotReload === 'ip') { + Object.assign(browserSyncConfig, { host: appIp }) + } + + // eslint-disable-next-line no-console + console.log(logId, 'BrowserSync enabled (' + chalk.green(hotReload) + '), proxy:', chalk.cyan(appUrl)) + // eslint-disable-next-line no-console + console.log( + logId, + chalk.dim( + 'BS listen: ' + + chalk.bold(':' + browserSyncPort) + + ' | WordPress port (proxy): ' + + chalk.bold(String(wordPressPort)) + + (urlHadPort + ? localAppPortStr + ? ' | BROWSER_SYNC_APP_PORT = BrowserSync port' + : ' | BROWSER_SYNC_APP_PORT unset → BS default port' + : ' | BROWSER_SYNC_APP_PORT = WordPress port (URL had no port)') + ) + ) + // eslint-disable-next-line no-console + console.log(logId, chalk.dim('Use BrowserSync Local / External URL below, not the WordPress URL directly.')) + + return [ + new webpack.HotModuleReplacementPlugin(), + new BrowserSyncPlugin(browserSyncConfig, { + reload: false, + }), + ] + } + + /** + * @returns {null|{ appUrl: string, urlHadPort: boolean, wordPressPort: number, localAppPortStr: string }} + */ + static parseAppConnection(env) { + let base = String(env.BROWSER_SYNC_APP_URL || '').trim() + const localPort = String(env.BROWSER_SYNC_APP_PORT || '').trim() + + if (!base) { + return null + } + + if (!/^https?:\/\//i.test(base)) { + base = 'http://' + base + } + + try { + const url = new URL(base) + const urlHadPort = Boolean(url.port) + if (!urlHadPort && localPort) { + url.port = localPort + } + const appUrl = url.toString().replace(/\/+$/, '') + const u2 = new URL(appUrl) + const wordPressPort = u2.port ? Number.parseInt(u2.port, 10) : u2.protocol === 'https:' ? 443 : 80 + + return { + appUrl, + urlHadPort, + wordPressPort, + localAppPortStr: localPort, + } + } catch { + return null + } + } + + /** + * @param {number} wordPressPort + * @param {string} localAppPortStr + * @param {boolean} urlHadPort + * @returns {number} + */ + static pickBrowserSyncPort(wordPressPort, localAppPortStr, urlHadPort) { + const wp = wordPressPort + const preferredRaw = Number.parseInt(String(localAppPortStr || '').trim(), 10) + const hasPreferred = Number.isFinite(preferredRaw) && preferredRaw > 0 + + let candidate + if (urlHadPort) { + if (hasPreferred) { + candidate = preferredRaw + } else { + candidate = 8080 + } + } else { + candidate = 8080 + } + + let warned = false + let safety = 0 + while (candidate === wp && safety < 10000) { + if (!warned) { + warned = true + // eslint-disable-next-line no-console + console.warn( + logId, + chalk.yellow('BrowserSync port would match WordPress port ' + wp + '. Using next available port.') + ) + } + candidate++ + if (candidate > 65535) { + candidate = 3000 + } + safety++ + } + + return candidate + } + + /** + * @returns {''|'host'|'ip'} + */ + static resolveMode() { + const fromEnv = String(process.env.BROWSERSYNC_MODE || '') + .trim() + .toLowerCase() + if (fromEnv === 'host' || fromEnv === 'ip') { + return fromEnv + } + return '' + } + + static getLocalAppEnv() { + const defaults = this.readWpEnvConfigDefaults() + return { + BROWSER_SYNC_APP_URL: process.env.BROWSER_SYNC_APP_URL ?? defaults.BROWSER_SYNC_APP_URL ?? '', + BROWSER_SYNC_APP_PORT: process.env.BROWSER_SYNC_APP_PORT ?? defaults.BROWSER_SYNC_APP_PORT ?? '', + BROWSER_SYNC_APP_IP: process.env.BROWSER_SYNC_APP_IP ?? defaults.BROWSER_SYNC_APP_IP ?? '', + } + } + + /** + * Reads `config` from `.wp-env.json` (project root). Env vars take precedence later. + * + * @returns {{ BROWSER_SYNC_APP_URL: string, BROWSER_SYNC_APP_PORT: string, BROWSER_SYNC_APP_IP: string }} + */ + static readWpEnvConfigDefaults() { + const empty = { BROWSER_SYNC_APP_URL: '', BROWSER_SYNC_APP_PORT: '', BROWSER_SYNC_APP_IP: '' } + const file = path.resolve(__dirname, '../.wp-env.json') + + try { + if (!fs.existsSync(file)) { + return empty + } + const json = JSON.parse(fs.readFileSync(file, 'utf8')) + const cfg = json.config + + if (!cfg || typeof cfg !== 'object') { + return empty + } + + return { + BROWSER_SYNC_APP_URL: cfg.BROWSER_SYNC_APP_URL != null ? String(cfg.BROWSER_SYNC_APP_URL) : '', + BROWSER_SYNC_APP_PORT: cfg.BROWSER_SYNC_APP_PORT != null ? String(cfg.BROWSER_SYNC_APP_PORT) : '', + BROWSER_SYNC_APP_IP: cfg.BROWSER_SYNC_APP_IP != null ? String(cfg.BROWSER_SYNC_APP_IP) : '', + } + } catch { + return empty + } + } + + /** + * @returns {{ missing: string[] }} + */ + static collectBrowserSyncConfigIssues(hotReload, env) { + const missing = [] + + const urlRaw = String(env.BROWSER_SYNC_APP_URL || '').trim() + if (!urlRaw) { + missing.push('BROWSER_SYNC_APP_URL') + } else { + let base = urlRaw + if (!/^https?:\/\//i.test(base)) { + base = 'http://' + base + } + /** @type {URL|null} */ + let url = null + try { + url = new URL(base) + } catch { + missing.push('BROWSER_SYNC_APP_URL') + } + if (url) { + const urlHadPort = Boolean(url.port) + const portVar = String(env.BROWSER_SYNC_APP_PORT || '').trim() + if (!urlHadPort && !portVar) { + missing.push('BROWSER_SYNC_APP_PORT') + } + } + } + + if (hotReload === 'ip') { + const ip = String(env.BROWSER_SYNC_APP_IP || '') + .trim() + .replace(/:\d+$/, '') + if (!ip) { + missing.push('BROWSER_SYNC_APP_IP') + } + } + + return { missing } + } + + /** + * First line: fatal summary. Second line: Missing vars only. then exit(1). + */ + static fatalConfig(hotReload, issues) { + const head = `Cannot start BrowserSync with ${chalk.yellow('--' + hotReload)}.` + // eslint-disable-next-line no-console + console.error(`${logId} ${chalk.white.bold.bgRed(' ERROR ')} ${head}`) + + const names = [...new Set(issues.missing || [])] + if (names.length > 0) { + // eslint-disable-next-line no-console + console.error( + `${logId} ${chalk.red('Missing:')} ` + names.map((name) => chalk.yellow.bold(name)).join(chalk.dim(', ')) + ) + } + process.exit(1) + } +} + +module.exports = WebpackBrowserSyncPlugin diff --git a/config/webpack-start.js b/config/webpack-start.js new file mode 100644 index 00000000..a83eb05f --- /dev/null +++ b/config/webpack-start.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +/** + * Dev server entry: `package.json` script `start` runs this so extra CLI flags reach webpack safely. + * Strips `--host` / `--ip` (webpack-cli rejects them) and sets `BROWSERSYNC_MODE` for BrowserSync. + * Examples: `yarn start`, `yarn start -- --host`, `yarn start -- --ip` (or `yarn start:host` / `yarn start:ip`). + */ + +const path = require('path') +const { spawnSync } = require('child_process') + +const root = path.resolve(__dirname, '..') +const passed = process.argv.slice(2) + +let browserSyncMode = '' +const webpackExtraArgs = passed.filter((arg) => { + if (arg === '--host') { + browserSyncMode = 'host' + return false + } + if (arg === '--ip') { + browserSyncMode = 'ip' + return false + } + return true +}) + +const env = { ...process.env } +if (browserSyncMode) { + env.BROWSERSYNC_MODE = browserSyncMode +} + +const webpackCli = require.resolve('webpack-cli/bin/cli.js') +const childArgs = [webpackCli, '--watch', '--config', 'config/webpack.dev.js', ...webpackExtraArgs] + +const result = spawnSync(process.execPath, childArgs, { + cwd: root, + stdio: 'inherit', + env, +}) + +process.exit(result.status !== null && result.status !== undefined ? result.status : 1) diff --git a/package.json b/package.json index 7d56e550..4ba9ba89 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ }, "scripts": { "start": "yarn webpack --watch --config config/webpack.dev.js", + "start:host": "node config/webpack-start.js --host", + "start:ip": "node config/webpack-start.js --ip", "build": "yarn webpack --config config/webpack.prod.js", "lint:css": "node_modules/.bin/stylelint \"src/scss/**/*.scss\"", "lint:js": "node_modules/.bin/eslint \"src/js/**/*.js\"", @@ -27,6 +29,8 @@ "@wordpress/dom-ready": "^3.17.0", "@wordpress/hooks": "^3.17.0", "@wordpress/stylelint-config": "^21.0.0", + "browser-sync": "^3.0.4", + "browser-sync-webpack-plugin": "^2.4.0", "clean-webpack-plugin": "^4.0.0-alpha.0", "concurrently": "^8.2.2", "css-loader": "^5.2.4",