From 8fd881e27b191b9a3667693e18073083e6d3ec8c Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Thu, 8 Oct 2026 15:57:05 +0200 Subject: [PATCH 1/2] feat(vite-config): fan the dev server hot file out to every theme A single dev server means laravel-vite-plugin writes one hot file, into the public directory of the theme hosting it. Standalone parent themes had no public/hot of their own and ended up hardcoding the path to sage's. The plugin copies the hot file into every theme's public directory on server start and removes the copies on shutdown. A hard kill leaves them behind, so the toolkit gains a clean command that removes leftovers. --- packages/toolkit/README.md | 5 + packages/toolkit/src/actions/clean.js | 41 ++++++ packages/toolkit/src/config/actions.js | 5 + packages/toolkit/src/config/help.js | 1 + packages/vite-config/README.md | 6 + packages/vite-config/src/configs/brave.js | 25 +++- .../src/plugins/write-theme-hot-files.js | 118 ++++++++++++++++++ .../tests/write-theme-hot-files.test.js | 86 +++++++++++++ packages/vite-config/vitest.config.mjs | 9 ++ vitest.config.mjs | 1 + 10 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 packages/toolkit/src/actions/clean.js create mode 100644 packages/vite-config/src/plugins/write-theme-hot-files.js create mode 100644 packages/vite-config/tests/write-theme-hot-files.test.js create mode 100644 packages/vite-config/vitest.config.mjs diff --git a/packages/toolkit/README.md b/packages/toolkit/README.md index 0b97ea9..07ebdae 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -51,6 +51,11 @@ Options Same commands either way. +```bash +# Removes leftover Vite hot files after a dev server was killed hard. +yard-toolkit clean +``` + ### Format For default for brave sites: diff --git a/packages/toolkit/src/actions/clean.js b/packages/toolkit/src/actions/clean.js new file mode 100644 index 0000000..c766ed5 --- /dev/null +++ b/packages/toolkit/src/actions/clean.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import fs from 'fs'; +import path from 'path'; + +/** + * Internal dependencies + */ +import { resolveThemeContext } from '@yardinternet/shared-utils'; +import log from '../utils/logger.js'; + +/** + * Removes leftover Vite hot files. + * + * The dev server deletes them on shutdown, but a hard kill leaves them behind + * and the site then points at a dev server that is no longer running. + */ +export const clean = () => { + const context = resolveThemeContext(); + let removed = 0; + + context.themes.forEach( ( theme ) => { + const hotFile = path.join( theme.dir, 'public', 'hot' ); + + if ( ! fs.existsSync( hotFile ) ) { + return; + } + + fs.rmSync( hotFile ); + removed++; + log.info( `Removed ${ path.join( theme.relDir, 'public', 'hot' ) }` ); + } ); + + if ( removed === 0 ) { + log.info( 'No hot files found.' ); + return; + } + + log.success( `Removed ${ removed } hot file(s).` ); +}; diff --git a/packages/toolkit/src/config/actions.js b/packages/toolkit/src/config/actions.js index 414a026..8d9ed34 100644 --- a/packages/toolkit/src/config/actions.js +++ b/packages/toolkit/src/config/actions.js @@ -1,5 +1,6 @@ import { watch } from '../actions/watch.js'; import { build } from '../actions/build.js'; +import { clean } from '../actions/clean.js'; import { format } from '../actions/format.js'; import { lint } from '../actions/lint.js'; @@ -20,4 +21,8 @@ export const actions = { name: 'lint', func: lint, }, + clean: { + name: 'clean', + func: clean, + }, }; diff --git a/packages/toolkit/src/config/help.js b/packages/toolkit/src/config/help.js index decc7ee..2d4eae0 100644 --- a/packages/toolkit/src/config/help.js +++ b/packages/toolkit/src/config/help.js @@ -13,6 +13,7 @@ Actions ${ actions.lint.name } Runs linter for specified filetype ${ actions.watch.name } Runs Vite watcher ${ actions.build.name } Runs Vite build + ${ actions.clean.name } Removes leftover Vite hot files Filetypes ${ filetypes.js.name } JavaScript files (*${ diff --git a/packages/vite-config/README.md b/packages/vite-config/README.md index 193c44b..7676dcc 100644 --- a/packages/vite-config/README.md +++ b/packages/vite-config/README.md @@ -90,6 +90,12 @@ export default defineConfig( Same config file either way. +### Hot file and multiple parent themes + +Dev runs a single Vite server, hosted by the default theme. Its hot file is copied into every theme's `public/` directory on start and removed on shutdown, so each theme — including a standalone parent theme that is not a child of the default one — resolves the dev server through its own `get_parent_theme_file_path('public/hot')`. + +A hard kill (`SIGKILL`) leaves the copies behind, which points the site at a dev server that is no longer running. `yard-toolkit clean` removes them. + ## Package Vite configs Presets for npm and Laravel packages. Both wrappers use `createBasePackageConfig`. diff --git a/packages/vite-config/src/configs/brave.js b/packages/vite-config/src/configs/brave.js index c764e23..0b9f71a 100644 --- a/packages/vite-config/src/configs/brave.js +++ b/packages/vite-config/src/configs/brave.js @@ -6,7 +6,7 @@ * Development Mode: * - Starts a single dev server. * - Automatically processes all themes. - * - Uses `web/app/themes/sage/public` as the public directory. + * - Uses the default theme's `public` directory, and fans its hot file out to every other theme. * * Build Mode: * - A Node script runs this config concurrently per theme. @@ -31,6 +31,7 @@ import { generateAliases } from '../utils/generate-aliases.js'; import { generateEntryPoints } from '../utils/generate-entry-points.js'; import { getCheckerPlugin } from '../utils/get-checker-plugin.js'; import { getPostCssPrefixWrapPlugin } from '../utils/get-postcss-prefixwrap-plugin.js'; +import { writeThemeHotFiles } from '../plugins/write-theme-hot-files.js'; import { getAllThemeNames, resolveThemeContext, @@ -72,6 +73,15 @@ export const braveConfig = ( { ? `/wp-content/themes/${ resolvedTheme }/public/build/` : `/app/themes/${ resolvedTheme }/public/build/`; + /** + * The theme hosting the dev server; the hot file laravel-vite-plugin writes + * lives here and is fanned out to the other themes below. + */ + const publicDirectory = path.join( + context.themeRelDir( resolvedTheme ), + 'public' + ); + return defineConfig( { base: isDev ? '' : buildBase, /** @@ -150,10 +160,7 @@ export const braveConfig = ( { * - Build mode: output to the specific theme directory * - theme-root: the theme's own `public` directory */ - publicDirectory: path.join( - context.themeRelDir( resolvedTheme ), - 'public' - ), + publicDirectory, /** * Files to watch for changes and trigger a refresh */ @@ -164,6 +171,14 @@ export const braveConfig = ( { 'web/app/themes/**/resources/views/**/*.blade.php', ], } ), + /** + * Gives every theme its own `public/hot`, so a standalone parent + * theme no longer has to borrow the dev server theme's hot file. + */ + writeThemeHotFiles( { + context, + sourceHotFile: path.join( publicDirectory, 'hot' ), + } ), /** * Externalizes React, ReactDOM and ReactJSXRuntime so they reference the global versions provided by WordPress' wp-element (window.React, window.ReactDOM). * This prevents bundling a duplicate React instance, which would break hooks and cause "Invalid hook call" errors. diff --git a/packages/vite-config/src/plugins/write-theme-hot-files.js b/packages/vite-config/src/plugins/write-theme-hot-files.js new file mode 100644 index 0000000..d28b572 --- /dev/null +++ b/packages/vite-config/src/plugins/write-theme-hot-files.js @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import fs from 'fs'; +import path from 'path'; + +/** + * Fans the dev server's hot file out to every theme. + * + * A single dev server means laravel-vite-plugin writes exactly one hot file, + * into the public directory of the theme hosting the server. Any other parent + * theme therefore has no `public/hot`, and `get_parent_theme_file_path()` on + * the PHP side comes up empty — which is why standalone themes ended up + * hardcoding the path to sage's hot file. + * + * This copies that hot file into every theme's public directory on server + * start and removes the copies on shutdown, so each theme can resolve its own. + */ +const MAX_ATTEMPTS = 20; +const RETRY_DELAY = 50; + +/** + * Module scope, so a server restart — which builds a new plugin instance — + * still cleans up the files the previous one wrote. + */ +const written = new Set(); +let exitHandlersBound = false; + +const clean = () => { + for ( const target of written ) { + try { + fs.rmSync( target ); + } catch { + // Already gone, or never created. + } + } + + written.clear(); +}; + +export const writeThemeHotFiles = ( { context, sourceHotFile } ) => { + return { + name: 'yard:write-theme-hot-files', + apply: 'serve', + config: () => ( { + server: { + watch: { + ignored: [ '**/public/hot' ], + }, + }, + } ), + configureServer( server ) { + const fanOut = ( attempt = 0 ) => { + if ( ! fs.existsSync( sourceHotFile ) ) { + if ( attempt < MAX_ATTEMPTS ) { + setTimeout( () => fanOut( attempt + 1 ), RETRY_DELAY ); + + return; + } + + server.config.logger.warn( + `[yard] No hot file at ${ sourceHotFile }; themes cannot resolve the dev server.` + ); + + return; + } + + const contents = fs.readFileSync( sourceHotFile, 'utf-8' ); + + context.themes.forEach( ( theme ) => { + const target = path.join( theme.dir, 'public', 'hot' ); + + if ( + path.resolve( target ) === path.resolve( sourceHotFile ) + ) { + return; + } + + try { + fs.mkdirSync( path.dirname( target ), { + recursive: true, + } ); + fs.writeFileSync( target, contents ); + written.add( target ); + server.watcher.unwatch( target ); + } catch ( error ) { + server.config.logger.warn( + `[yard] Could not write hot file for theme "${ theme.name }": ${ error.message }` + ); + } + } ); + }; + + /** + * laravel-vite-plugin writes the hot file from its own `listening` + * handler and is `enforce: 'post'`, so plugin order does not + * guarantee it ran first. Deferring past the current tick does. + */ + server.httpServer?.once( 'listening', () => + setImmediate( () => fanOut() ) + ); + + /** + * Vite restarts the server on config changes, running this hook + * again — bind the process handlers only once. + */ + if ( exitHandlersBound ) { + return; + } + + process.on( 'exit', clean ); + process.on( 'SIGINT', () => process.exit() ); + process.on( 'SIGTERM', () => process.exit() ); + process.on( 'SIGHUP', () => process.exit() ); + exitHandlersBound = true; + }, + }; +}; diff --git a/packages/vite-config/tests/write-theme-hot-files.test.js b/packages/vite-config/tests/write-theme-hot-files.test.js new file mode 100644 index 0000000..bd8074d --- /dev/null +++ b/packages/vite-config/tests/write-theme-hot-files.test.js @@ -0,0 +1,86 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { EventEmitter } from 'events'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { writeThemeHotFiles } from '../src/plugins/write-theme-hot-files.js'; + +const tempRoots = []; + +const makeProject = ( themeNames ) => { + const root = fs.mkdtempSync( path.join( os.tmpdir(), 'yard-hot-' ) ); + tempRoots.push( root ); + + const themes = themeNames.map( ( name ) => { + const dir = path.join( root, 'themes', name ); + fs.mkdirSync( dir, { recursive: true } ); + + return { name, dir }; + } ); + + return { root, context: { themes } }; +}; + +const makeServer = () => ( { + httpServer: new EventEmitter(), + watcher: { unwatch: () => {} }, + config: { logger: { warn: () => {} } }, +} ); + +const flush = () => new Promise( ( resolve ) => setTimeout( resolve, 20 ) ); + +afterEach( () => { + while ( tempRoots.length ) { + fs.rmSync( tempRoots.pop(), { recursive: true, force: true } ); + } +} ); + +describe( 'writeThemeHotFiles', () => { + test( 'copies the dev server hot file into every other theme', async () => { + const { context } = makeProject( [ 'sage', 'idee', 'eiland' ] ); + const sourceHotFile = path.join( + context.themes[ 0 ].dir, + 'public', + 'hot' + ); + + fs.mkdirSync( path.dirname( sourceHotFile ), { recursive: true } ); + fs.writeFileSync( sourceHotFile, 'http://localhost:5173' ); + + const plugin = writeThemeHotFiles( { context, sourceHotFile } ); + const server = makeServer(); + + plugin.configureServer( server ); + server.httpServer.emit( 'listening' ); + await flush(); + + context.themes.forEach( ( theme ) => { + const hotFile = path.join( theme.dir, 'public', 'hot' ); + + expect( fs.readFileSync( hotFile, 'utf8' ) ).toBe( + 'http://localhost:5173' + ); + } ); + } ); + + test( 'ignores the hot file inside the watcher config', () => { + const { context } = makeProject( [ 'sage' ] ); + const plugin = writeThemeHotFiles( { + context, + sourceHotFile: 'unused', + } ); + + expect( plugin.config().server.watch.ignored ).toContain( + '**/public/hot' + ); + } ); + + test( 'only applies to the dev server', () => { + const { context } = makeProject( [ 'sage' ] ); + + expect( + writeThemeHotFiles( { context, sourceHotFile: 'unused' } ).apply + ).toBe( 'serve' ); + } ); +} ); diff --git a/packages/vite-config/vitest.config.mjs b/packages/vite-config/vitest.config.mjs new file mode 100644 index 0000000..770920c --- /dev/null +++ b/packages/vite-config/vitest.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.js'], + }, +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index d82281c..69a81ff 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -5,6 +5,7 @@ export default defineConfig( { projects: [ 'packages/eslint-config/vitest.config.mjs', 'packages/prettier-config/vitest.config.mjs', + 'packages/vite-config/vitest.config.mjs', 'packages/toolkit/vitest.config.mjs', 'packages/wp-scripts-config/vitest.config.mjs', ], From d4ae5c5189220dfb676a78aa1df207b3260933e3 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Fri, 9 Oct 2026 08:51:28 +0200 Subject: [PATCH 2/2] chore: make comments more concise --- packages/toolkit/src/actions/clean.js | 5 +--- packages/vite-config/README.md | 4 ++-- packages/vite-config/src/configs/brave.js | 8 +++---- .../src/plugins/write-theme-hot-files.js | 23 ++++--------------- 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/packages/toolkit/src/actions/clean.js b/packages/toolkit/src/actions/clean.js index c766ed5..69a1744 100644 --- a/packages/toolkit/src/actions/clean.js +++ b/packages/toolkit/src/actions/clean.js @@ -11,10 +11,7 @@ import { resolveThemeContext } from '@yardinternet/shared-utils'; import log from '../utils/logger.js'; /** - * Removes leftover Vite hot files. - * - * The dev server deletes them on shutdown, but a hard kill leaves them behind - * and the site then points at a dev server that is no longer running. + * Removes leftover Vite hot files from themes. A hard kill leaves them behind. */ export const clean = () => { const context = resolveThemeContext(); diff --git a/packages/vite-config/README.md b/packages/vite-config/README.md index 7676dcc..67701e4 100644 --- a/packages/vite-config/README.md +++ b/packages/vite-config/README.md @@ -92,9 +92,9 @@ Same config file either way. ### Hot file and multiple parent themes -Dev runs a single Vite server, hosted by the default theme. Its hot file is copied into every theme's `public/` directory on start and removed on shutdown, so each theme — including a standalone parent theme that is not a child of the default one — resolves the dev server through its own `get_parent_theme_file_path('public/hot')`. +Dev uses one Vite server hosted by the default theme. Its hot file is copied to every theme's `public/` directory on startup and removed on shutdown, allowing standalone parent themes to resolve the server via `get_parent_theme_file_path('public/hot')`. -A hard kill (`SIGKILL`) leaves the copies behind, which points the site at a dev server that is no longer running. `yard-toolkit clean` removes them. +A `SIGKILL` may leave stale copies behind. Run `yard-toolkit clean` to remove them. ## Package Vite configs diff --git a/packages/vite-config/src/configs/brave.js b/packages/vite-config/src/configs/brave.js index 0b9f71a..d618e1f 100644 --- a/packages/vite-config/src/configs/brave.js +++ b/packages/vite-config/src/configs/brave.js @@ -6,7 +6,7 @@ * Development Mode: * - Starts a single dev server. * - Automatically processes all themes. - * - Uses the default theme's `public` directory, and fans its hot file out to every other theme. + * - Uses the default theme's `public` directory + writes its hot file out to every other theme. * * Build Mode: * - A Node script runs this config concurrently per theme. @@ -74,8 +74,7 @@ export const braveConfig = ( { : `/app/themes/${ resolvedTheme }/public/build/`; /** - * The theme hosting the dev server; the hot file laravel-vite-plugin writes - * lives here and is fanned out to the other themes below. + * The theme hosting the dev server; the hot file laravel-vite-plugin writes lives here and is written out to the other themes. */ const publicDirectory = path.join( context.themeRelDir( resolvedTheme ), @@ -172,8 +171,7 @@ export const braveConfig = ( { ], } ), /** - * Gives every theme its own `public/hot`, so a standalone parent - * theme no longer has to borrow the dev server theme's hot file. + * Gives every theme its own `public/hot` file */ writeThemeHotFiles( { context, diff --git a/packages/vite-config/src/plugins/write-theme-hot-files.js b/packages/vite-config/src/plugins/write-theme-hot-files.js index d28b572..eebdbfe 100644 --- a/packages/vite-config/src/plugins/write-theme-hot-files.js +++ b/packages/vite-config/src/plugins/write-theme-hot-files.js @@ -5,23 +5,13 @@ import fs from 'fs'; import path from 'path'; /** - * Fans the dev server's hot file out to every theme. - * - * A single dev server means laravel-vite-plugin writes exactly one hot file, - * into the public directory of the theme hosting the server. Any other parent - * theme therefore has no `public/hot`, and `get_parent_theme_file_path()` on - * the PHP side comes up empty — which is why standalone themes ended up - * hardcoding the path to sage's hot file. - * - * This copies that hot file into every theme's public directory on server - * start and removes the copies on shutdown, so each theme can resolve its own. + * Writes the dev server's public/hot file out to every theme. */ const MAX_ATTEMPTS = 20; const RETRY_DELAY = 50; /** - * Module scope, so a server restart — which builds a new plugin instance — - * still cleans up the files the previous one wrote. + * Module scope, so a server restart still cleans up the files the previous one wrote. */ const written = new Set(); let exitHandlersBound = false; @@ -31,7 +21,7 @@ const clean = () => { try { fs.rmSync( target ); } catch { - // Already gone, or never created. + // Already gone, or never created } } @@ -92,17 +82,14 @@ export const writeThemeHotFiles = ( { context, sourceHotFile } ) => { }; /** - * laravel-vite-plugin writes the hot file from its own `listening` - * handler and is `enforce: 'post'`, so plugin order does not - * guarantee it ran first. Deferring past the current tick does. + * laravel-vite-plugin writes the hot file from its own `listening` handler and is `enforce: 'post'`, so plugin order does not guarantee it ran first. Deferring past the current tick does. */ server.httpServer?.once( 'listening', () => setImmediate( () => fanOut() ) ); /** - * Vite restarts the server on config changes, running this hook - * again — bind the process handlers only once. + * Vite restarts the server on config changes, running this hook again. */ if ( exitHandlersBound ) { return;