Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment on lines +54 to +57

### Format

For default for brave sites:
Expand Down
38 changes: 38 additions & 0 deletions packages/toolkit/src/actions/clean.js
Original file line number Diff line number Diff line change
@@ -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).` );
};
5 changes: 5 additions & 0 deletions packages/toolkit/src/config/actions.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -20,4 +21,8 @@ export const actions = {
name: 'lint',
func: lint,
},
clean: {
name: 'clean',
func: clean,
},
};
1 change: 1 addition & 0 deletions packages/toolkit/src/config/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (*${
Expand Down
6 changes: 6 additions & 0 deletions packages/vite-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
23 changes: 18 additions & 5 deletions packages/vite-config/src/configs/brave.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
/**
Expand Down Expand Up @@ -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
*/
Expand All @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions packages/vite-config/src/plugins/write-theme-hot-files.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
};
86 changes: 86 additions & 0 deletions packages/vite-config/tests/write-theme-hot-files.test.js
Original file line number Diff line number Diff line change
@@ -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' );
} );
} );
9 changes: 9 additions & 0 deletions packages/vite-config/vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.js'],
},
});
1 change: 1 addition & 0 deletions vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
Loading