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..69a1744 --- /dev/null +++ b/packages/toolkit/src/actions/clean.js @@ -0,0 +1,38 @@ +/** + * 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 from themes. A hard kill leaves them behind. + */ +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..67701e4 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 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 `SIGKILL` may leave stale copies behind. Run `yard-toolkit clean` to remove 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..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 `web/app/themes/sage/public` as the public directory. + * - 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. @@ -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,14 @@ 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 written out to the other themes. + */ + const publicDirectory = path.join( + context.themeRelDir( resolvedTheme ), + 'public' + ); + return defineConfig( { base: isDev ? '' : buildBase, /** @@ -150,10 +159,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 +170,13 @@ export const braveConfig = ( { 'web/app/themes/**/resources/views/**/*.blade.php', ], } ), + /** + * Gives every theme its own `public/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..eebdbfe --- /dev/null +++ b/packages/vite-config/src/plugins/write-theme-hot-files.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import fs from 'fs'; +import path from 'path'; + +/** + * 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 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. + */ + 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', ],