diff --git a/packages/shared-utils/README.md b/packages/shared-utils/README.md index 31187a2..696a00e 100644 --- a/packages/shared-utils/README.md +++ b/packages/shared-utils/README.md @@ -1,13 +1,91 @@ # @yardinternet/shared-utils -Shared utilities used internally by `@yardinternet/toolkit` and `@yardinternet/vite-config`. +Shared utilities used internally by the other toolkit packages. It resolves the +project layout — where the themes live, which theme is the parent, and what URL +the built assets are served from — so every package agrees on one answer. + +Ships both formats without a build step: ESM consumers `import`, CommonJS +consumers (eslint, prettier and postcss configs) `require`. ## Usage ```js import { resolveThemeContext, getAllThemeNames } from '@yardinternet/shared-utils'; + +const { resolveThemeContext } = require( '@yardinternet/shared-utils' ); ``` +## Project layout detection + +Two layouts are supported: + +- **brave-root** — cwd is the project root and themes live in a themes + directory. Tooling operates on every theme. +- **theme-root** — cwd is a single theme (it has a `style.css` and no themes + directory). Every path collapses to the cwd, so a theme builds, watches, lints + and formats on its own. + +### Themes directory + +Resolved in this order, first hit wins. A candidate only counts when it actually +contains a theme, and `web/app/themes` is probed first, so existing Bedrock +projects resolve exactly as they always did. + +1. `themesDir` in `yard-toolkit.config.json`, or in the `yardToolkit` key of + `package.json`. +2. The `YARD_THEMES_DIR` environment variable. +3. `composer.json` → `extra.installer-paths` entry for `type:wordpress-theme`. +4. A probe of the known layouts: `web/app/themes`, `wp-content/themes`, + `app/themes`, `public/wp-content/themes`, `content/themes`. +5. A `style.css` in the cwd → theme-root. + +Nothing matched throws, listing what was tried. + +### Asset base URL + +The URL prefix built assets are served from is derived from where the themes +directory sits relative to the WordPress docroot (the nearest ancestor holding +an `index.php` or `wp-config.php`): + +| Layout | Themes directory | Asset base URL | +| -------------------------- | -------------------- | -------------------- | +| Bedrock | `web/app/themes` | `/app/themes` | +| Classic WordPress | `wp-content/themes` | `/wp-content/themes` | + +Override with `themesBaseUrl` in the config when a project serves its docroot +from somewhere unusual. + +### Parent and child themes + +Read from each theme's `style.css` header: a `Template:` field means the theme +is a child of that template, its absence means the theme is a parent. No +configuration involved — this is what WordPress itself uses. + +The default theme (the one hosting the dev server, and the target of the +`@sage` compatibility aliases) is picked in this order: the configured +`defaultTheme`, the only parent, a parent named `sage`, then the first parent +alphabetically with a warning. + +### Configuration + +Only needed when auto-detection cannot get there: + +```jsonc +// yard-toolkit.config.json, or the "yardToolkit" key in package.json +{ + "themesDir": "site/wp-content/themes", + "themesBaseUrl": "/wp-content/themes", + "defaultTheme": "basis" +} +``` + +Run `yard-toolkit info` to see what was resolved and which layer won. + ## Contents -- **Theme context** (`resolveThemeContext`, `getAllThemeNames`) — theme discovery for brave-root / theme-root project layouts. +- `resolveThemeContext( { cwd } )` — the full resolved layout. Memoized per + directory; throws when nothing resolves. +- `tryResolveThemeContext( { cwd } )` — same, returning `null` instead of + throwing. Use it in configs loaded by long-lived editor processes. +- `getAllThemeNames()`, `getParentThemes()`, `getThemesBaseUrl()`. +- `clearThemeContextCache()` — for tests. diff --git a/packages/shared-utils/package.json b/packages/shared-utils/package.json index 342c255..c075aa7 100644 --- a/packages/shared-utils/package.json +++ b/packages/shared-utils/package.json @@ -4,8 +4,15 @@ "description": "Shared utilities used by @yardinternet/toolkit and @yardinternet/vite-config", "main": "src/index.js", "type": "module", + "exports": { + ".": { + "require": "./src/resolve-theme-context.cjs", + "import": "./src/index.js" + }, + "./package.json": "./package.json" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run" }, "publishConfig": { "registry": "https://npm.pkg.github.com/" diff --git a/packages/shared-utils/src/index.js b/packages/shared-utils/src/index.js index 067ea9b..b03dfba 100644 --- a/packages/shared-utils/src/index.js +++ b/packages/shared-utils/src/index.js @@ -1,2 +1,16 @@ -export { resolveThemeContext } from './resolve-theme-context.js'; -export { getAllThemeNames } from './get-all-theme-names.js'; +/** + * ESM facade over the CommonJS resolver, so the CJS configs (eslint, prettier, + * postcss) and the ESM packages (toolkit, vite-config) share one + * implementation without a build step. + * + * Properties are read off the default export rather than re-exported by name, + * so resolution never depends on CJS named-export detection. + */ +import themeContext from './resolve-theme-context.cjs'; + +export const resolveThemeContext = themeContext.resolveThemeContext; +export const tryResolveThemeContext = themeContext.tryResolveThemeContext; +export const getAllThemeNames = themeContext.getAllThemeNames; +export const getParentThemes = themeContext.getParentThemes; +export const getThemesBaseUrl = themeContext.getThemesBaseUrl; +export const clearThemeContextCache = themeContext.clearThemeContextCache; diff --git a/packages/shared-utils/src/resolve-theme-context.cjs b/packages/shared-utils/src/resolve-theme-context.cjs new file mode 100644 index 0000000..4882ff0 --- /dev/null +++ b/packages/shared-utils/src/resolve-theme-context.cjs @@ -0,0 +1,501 @@ +'use strict'; + +/** + * Resolves the project layout for build/watch/lint/format tooling. + * + * Two layouts are supported: + * + * - `brave-root` (default): invoked from the root of a Bedrock-style project + * where themes live in a themes directory such as `web/app/themes/`. + * - `theme-root`: invoked from inside a single theme directory (cwd is the + * theme). Every theme path collapses to the cwd, so a theme can build, watch, + * lint and format on its own. + * + * Authored as CommonJS so the CJS configs (eslint, prettier, postcss) and the + * ESM packages (toolkit, vite-config) share one implementation without a build + * step. `src/index.js` re-exports it for ESM consumers. + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +/** + * Probed in order, first hit wins. `web/app/themes` is first so every existing + * Brave project resolves exactly as it did before this file grew a resolver. + */ +const THEMES_DIR_CANDIDATES = [ + 'web/app/themes', + 'wp-content/themes', + 'app/themes', + 'public/wp-content/themes', + 'content/themes', +]; + +const DOCROOT_MARKERS = [ 'index.php', 'wp-config.php' ]; + +/** + * Only used when no docroot marker is present on disk. + */ +const DOCROOT_DIR_NAMES = [ 'web', 'public', 'public_html', 'htdocs', 'www' ]; + +/** + * A theme's `style.css` can be an entire compiled stylesheet, so only the + * header block is ever read. + */ +const STYLE_HEADER_BYTES = 8192; + +const CONFIG_FILENAME = 'yard-toolkit.config.json'; +const PACKAGE_JSON_KEY = 'yardToolkit'; +const ENV_THEMES_DIR = 'YARD_THEMES_DIR'; + +const cache = new Map(); +const warned = new Set(); + +const warnOnce = ( key, message ) => { + if ( warned.has( key ) ) { + return; + } + + warned.add( key ); + // eslint-disable-next-line no-console + console.warn( `[yard-toolkit] ${ message }` ); +}; + +const toPosix = ( value ) => value.split( path.sep ).join( '/' ); + +const isDirectory = ( target ) => { + try { + return fs.statSync( target ).isDirectory(); + } catch { + return false; + } +}; + +const isFile = ( target ) => { + try { + return fs.statSync( target ).isFile(); + } catch { + return false; + } +}; + +const readJson = ( target ) => { + try { + return JSON.parse( fs.readFileSync( target, 'utf8' ) ); + } catch { + return null; + } +}; + +/** + * Reads at most the first `STYLE_HEADER_BYTES` of a file. + */ +const readHead = ( target ) => { + let fd; + + try { + fd = fs.openSync( target, 'r' ); + const buffer = Buffer.alloc( STYLE_HEADER_BYTES ); + const bytesRead = fs.readSync( fd, buffer, 0, STYLE_HEADER_BYTES, 0 ); + + return buffer.toString( 'utf8', 0, bytesRead ); + } catch { + return ''; + } finally { + if ( fd !== undefined ) { + try { + fs.closeSync( fd ); + } catch { + // Nothing sensible to do here. + } + } + } +}; + +/** + * Reads a WordPress style.css header field. Tolerates both the plain + * `/* Key: value` and the asterisk-prefixed ` * Key: value` comment styles. + */ +const readStyleHeaderField = ( contents, field ) => { + const match = contents.match( + new RegExp( `^[\\s*/]*${ field }\\s*:\\s*(.+)$`, 'im' ) + ); + + if ( ! match ) { + return null; + } + + return match[ 1 ].replace( /\*+\/\s*$/, '' ).trim() || null; +}; + +const listThemeDirNames = ( themesDir ) => { + if ( ! isDirectory( themesDir ) ) { + return []; + } + + return fs + .readdirSync( themesDir ) + .filter( ( dirName ) => { + const fullPath = path.join( themesDir, dirName ); + + return ( + isDirectory( fullPath ) && + isFile( path.join( fullPath, 'style.css' ) ) + ); + } ) + .sort(); +}; + +const readProjectConfig = ( root ) => { + const configFile = readJson( path.join( root, CONFIG_FILENAME ) ); + + if ( configFile && typeof configFile === 'object' ) { + return configFile; + } + + const packageJson = readJson( path.join( root, 'package.json' ) ); + const fromPackage = packageJson && packageJson[ PACKAGE_JSON_KEY ]; + + if ( fromPackage && typeof fromPackage === 'object' ) { + return fromPackage; + } + + return {}; +}; + +/** + * Bedrock and friends declare their themes directory in composer.json: + * `extra.installer-paths` maps `web/app/themes/{$name}` to `type:wordpress-theme`. + */ +const themesDirFromComposer = ( root ) => { + const composer = readJson( path.join( root, 'composer.json' ) ); + const installerPaths = composer?.extra?.[ 'installer-paths' ]; + + if ( ! installerPaths || typeof installerPaths !== 'object' ) { + return null; + } + + for ( const [ pattern, targets ] of Object.entries( installerPaths ) ) { + const isThemePath = + Array.isArray( targets ) && + targets.some( + ( target ) => + typeof target === 'string' && + target.toLowerCase() === 'type:wordpress-theme' + ); + + if ( ! isThemePath || ! pattern.includes( '{$name}' ) ) { + continue; + } + + const dir = path.posix.dirname( pattern.replace( /\\/g, '/' ) ); + + if ( dir && dir !== '.' ) { + return dir; + } + } + + return null; +}; + +/** + * A candidate only counts when it holds at least one actual theme, which keeps + * a stale or unrelated directory from winning the resolution. + */ +const acceptThemesDir = ( root, relDir ) => { + if ( ! relDir ) { + return null; + } + + const dir = path.resolve( root, relDir ); + + if ( listThemeDirNames( dir ).length === 0 ) { + return null; + } + + return { dir, relDir: path.relative( root, dir ) }; +}; + +const detectThemesDir = ( root ) => { + const config = readProjectConfig( root ); + + const layers = [ + [ 'config', config.themesDir ], + [ 'env', process.env[ ENV_THEMES_DIR ] ], + [ 'composer', themesDirFromComposer( root ) ], + ...THEMES_DIR_CANDIDATES.map( ( candidate ) => [ + 'probe', + candidate, + ] ), + ]; + + for ( const [ source, candidate ] of layers ) { + const accepted = acceptThemesDir( root, candidate ); + + if ( accepted ) { + return { ...accepted, source, config }; + } + } + + return null; +}; + +/** + * The public URL prefix the built assets are served from. Bedrock serves + * `web/` as the docroot, so `web/app/themes` becomes `/app/themes`. + */ +const detectThemesBaseUrl = ( { root, themesDir, themesRelDir, config } ) => { + if ( config.themesBaseUrl ) { + return { themesBaseUrl: config.themesBaseUrl, docRoot: null }; + } + + let current = path.dirname( themesDir ); + + while ( current.startsWith( root ) && current !== path.dirname( current ) ) { + const isDocRoot = DOCROOT_MARKERS.some( ( marker ) => + isFile( path.join( current, marker ) ) + ); + + if ( isDocRoot ) { + return { + docRoot: current, + themesBaseUrl: + '/' + toPosix( path.relative( current, themesDir ) ), + }; + } + + if ( current === root ) { + break; + } + + current = path.dirname( current ); + } + + /** + * No docroot marker found — strip a leading segment only when it is a + * conventional docroot name. This reproduces the historical `/app/themes` + * for `web/app/themes`. + */ + const segments = toPosix( themesRelDir ).split( '/' ); + const baseSegments = + segments.length > 1 && DOCROOT_DIR_NAMES.includes( segments[ 0 ] ) + ? segments.slice( 1 ) + : segments; + + return { docRoot: null, themesBaseUrl: '/' + baseSegments.join( '/' ) }; +}; + +const readTheme = ( { themesDir, root, name } ) => { + const dir = path.join( themesDir, name ); + const header = readHead( path.join( dir, 'style.css' ) ); + const template = readStyleHeaderField( header, 'Template' ); + const relDir = path.relative( root, dir ); + + return { + name, + dir, + relDir, + relDirPosix: toPosix( relDir ), + title: readStyleHeaderField( header, 'Theme Name' ) || name, + template, + isParent: ! template, + }; +}; + +const pickDefaultTheme = ( { themes, config } ) => { + if ( + config.defaultTheme && + themes.some( ( theme ) => theme.name === config.defaultTheme ) + ) { + return config.defaultTheme; + } + + const parents = themes.filter( ( theme ) => theme.isParent ); + + if ( parents.length === 1 ) { + return parents[ 0 ].name; + } + + if ( parents.some( ( theme ) => theme.name === 'sage' ) ) { + return 'sage'; + } + + const fallback = ( parents[ 0 ] || themes[ 0 ] )?.name; + + if ( parents.length > 1 ) { + warnOnce( + 'ambiguous-default-theme', + `Multiple parent themes found (${ parents + .map( ( theme ) => theme.name ) + .join( + ', ' + ) }). Falling back to "${ fallback }". Set "defaultTheme" in ${ CONFIG_FILENAME } or the "${ PACKAGE_JSON_KEY }" key of package.json to choose explicitly.` + ); + } + + return fallback; +}; + +const buildThemeRootContext = ( cwd ) => { + const name = path.basename( cwd ); + const theme = { + name, + dir: cwd, + relDir: '.', + relDirPosix: '.', + title: readStyleHeaderField( + readHead( path.join( cwd, 'style.css' ) ), + 'Theme Name' + ), + template: null, + isParent: true, + }; + + return { + mode: 'theme-root', + source: 'style.css', + projectRoot: cwd, + themesDir: path.dirname( cwd ), + themesRelDir: '.', + themesRelDirPosix: '.', + docRoot: null, + themesBaseUrl: '/wp-content/themes', + themes: [ theme ], + parents: [ theme ], + defaultTheme: name, + themeDir: () => cwd, + themeRelDir: () => '.', + themeRelDirPosix: () => '.', + themeForPath: () => theme, + }; +}; + +const buildBraveRootContext = ( root, detected ) => { + const { dir: themesDir, relDir: themesRelDir, source, config } = detected; + + const themes = listThemeDirNames( themesDir ).map( ( name ) => + readTheme( { themesDir, root, name } ) + ); + + const { docRoot, themesBaseUrl } = detectThemesBaseUrl( { + root, + themesDir, + themesRelDir, + config, + } ); + + const byName = new Map( themes.map( ( theme ) => [ theme.name, theme ] ) ); + + return { + mode: 'brave-root', + source, + projectRoot: root, + themesDir, + themesRelDir, + themesRelDirPosix: toPosix( themesRelDir ), + docRoot, + themesBaseUrl, + themes, + parents: themes.filter( ( theme ) => theme.isParent ), + defaultTheme: pickDefaultTheme( { themes, config } ), + themeDir: ( name ) => path.join( themesDir, name ), + themeRelDir: ( name ) => path.join( themesRelDir, name ), + themeRelDirPosix: ( name ) => + path.posix.join( toPosix( themesRelDir ), name ), + themeForPath: ( target ) => { + const absolute = path.resolve( root, target ); + + for ( const theme of themes ) { + if ( + absolute === theme.dir || + absolute.startsWith( theme.dir + path.sep ) + ) { + return theme; + } + } + + return null; + }, + getTheme: ( name ) => byName.get( name ) || null, + }; +}; + +const buildContext = ( cwd ) => { + const detected = detectThemesDir( cwd ); + + if ( detected ) { + return buildBraveRootContext( cwd, detected ); + } + + if ( isFile( path.join( cwd, 'style.css' ) ) ) { + return buildThemeRootContext( cwd ); + } + + return null; +}; + +/** + * Resolves the project context, memoized per starting directory. + * + * The starting directory is injectable because `process.cwd()` is unreliable + * in long-lived editor processes — the VSCode extension host reports `/` when + * the editor was launched from the Dock. + */ +const resolveThemeContext = ( { cwd = process.cwd() } = {} ) => { + const root = path.resolve( cwd ); + + if ( cache.has( root ) ) { + return cache.get( root ); + } + + const context = buildContext( root ); + + if ( ! context ) { + throw new Error( + `Unable to determine project context from "${ root }": no themes ` + + `directory (tried ${ THEMES_DIR_CANDIDATES.join( + ', ' + ) }) and no "style.css". Run from a project root or a theme ` + + `directory, or set "themesDir" in ${ CONFIG_FILENAME } or the ` + + `"${ PACKAGE_JSON_KEY }" key of package.json.` + ); + } + + cache.set( root, context ); + + return context; +}; + +/** + * Non-throwing variant, for configs loaded inside editor processes where a + * throw at require time would take down formatting or linting entirely. + */ +const tryResolveThemeContext = ( options ) => { + try { + return resolveThemeContext( options ); + } catch { + return null; + } +}; + +const getAllThemeNames = ( options ) => + resolveThemeContext( options ).themes.map( ( theme ) => theme.name ); + +const getParentThemes = ( options ) => resolveThemeContext( options ).parents; + +const getThemesBaseUrl = ( options ) => + resolveThemeContext( options ).themesBaseUrl; + +const clearThemeContextCache = () => { + cache.clear(); + warned.clear(); +}; + +module.exports = { + clearThemeContextCache, + getAllThemeNames, + getParentThemes, + getThemesBaseUrl, + resolveThemeContext, + tryResolveThemeContext, +}; diff --git a/packages/shared-utils/tests/fixtures/bedrock-custom-dir/composer.json b/packages/shared-utils/tests/fixtures/bedrock-custom-dir/composer.json new file mode 100644 index 0000000..5369e63 --- /dev/null +++ b/packages/shared-utils/tests/fixtures/bedrock-custom-dir/composer.json @@ -0,0 +1,9 @@ +{ + "extra": { + "installer-paths": { + "site/wp-content/themes/{$name}/": [ + "type:wordpress-theme" + ] + } + } +} \ No newline at end of file diff --git a/packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/index.php b/packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/index.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/index.php @@ -0,0 +1 @@ + path.join( __dirname, 'fixtures', name ); +const resolve = ( name ) => resolveThemeContext( { cwd: fixture( name ) } ); + +beforeEach( () => { + clearThemeContextCache(); + delete process.env.YARD_THEMES_DIR; +} ); + +describe( 'bedrock (the existing Brave layout)', () => { + test( 'resolves the historical themes dir and asset base url', () => { + const context = resolve( 'bedrock' ); + + expect( context.mode ).toBe( 'brave-root' ); + expect( context.themesRelDirPosix ).toBe( 'web/app/themes' ); + expect( context.themesBaseUrl ).toBe( '/app/themes' ); + expect( context.docRoot ).toBe( + path.join( fixture( 'bedrock' ), 'web' ) + ); + expect( context.defaultTheme ).toBe( 'sage' ); + } ); + + test( 'discovers themes and their parent/child relationship', () => { + const context = resolve( 'bedrock' ); + + expect( context.themes.map( ( theme ) => theme.name ) ).toEqual( [ + 'sage', + 'sage-child', + ] ); + expect( context.parents.map( ( theme ) => theme.name ) ).toEqual( [ + 'sage', + ] ); + expect( context.getTheme( 'sage-child' ).template ).toBe( 'sage' ); + expect( context.getTheme( 'sage' ).title ).toBe( 'Sage' ); + } ); + + test( 'themeRelDir matches the pre-refactor value', () => { + const context = resolve( 'bedrock' ); + + expect( context.themeRelDir( 'sage' ) ).toBe( + path.join( 'web', 'app', 'themes', 'sage' ) + ); + expect( context.themeRelDirPosix( 'sage' ) ).toBe( + 'web/app/themes/sage' + ); + } ); + + test( 'themeForPath maps a block path back to its theme', () => { + const context = resolve( 'bedrock' ); + const blockPath = path.join( + 'web', + 'app', + 'themes', + 'sage', + 'resources', + 'scripts', + 'blocks', + 'hero' + ); + + expect( context.themeForPath( blockPath ).name ).toBe( 'sage' ); + expect( context.themeForPath( 'web/app/plugins/foo' ) ).toBeNull(); + } ); +} ); + +describe( 'alternative layouts', () => { + test( 'composer installer-paths win over the probe list', () => { + const context = resolve( 'bedrock-custom-dir' ); + + expect( context.source ).toBe( 'composer' ); + expect( context.themesRelDirPosix ).toBe( 'site/wp-content/themes' ); + expect( context.themesBaseUrl ).toBe( '/wp-content/themes' ); + expect( context.defaultTheme ).toBe( 'main' ); + } ); + + test( 'classic wp-content layout resolves without a docroot subdir', () => { + const context = resolve( 'classic' ); + + expect( context.themesRelDirPosix ).toBe( 'wp-content/themes' ); + expect( context.themesBaseUrl ).toBe( '/wp-content/themes' ); + expect( context.docRoot ).toBe( fixture( 'classic' ) ); + } ); + + test( 'a stray wp-content/themes never beats web/app/themes', () => { + const context = resolve( 'decoy' ); + + expect( context.source ).toBe( 'probe' ); + expect( context.themesRelDirPosix ).toBe( 'web/app/themes' ); + } ); + + test( 'theme-root collapses every path to the cwd', () => { + const context = resolveThemeContext( { + cwd: path.join( fixture( 'theme-root' ), 'basis' ), + } ); + + expect( context.mode ).toBe( 'theme-root' ); + expect( context.defaultTheme ).toBe( 'basis' ); + expect( context.themeRelDir( 'basis' ) ).toBe( '.' ); + expect( context.themesBaseUrl ).toBe( '/wp-content/themes' ); + } ); +} ); + +describe( 'explicit configuration', () => { + test( 'package.json yardToolkit key sets dir and default theme', () => { + const context = resolve( 'configured' ); + + expect( context.source ).toBe( 'config' ); + expect( context.themesRelDirPosix ).toBe( 'src/themes' ); + expect( context.defaultTheme ).toBe( 'zulu' ); + } ); + + test( 'yard-toolkit.config.json can override the asset base url', () => { + const context = resolve( 'configured-file' ); + + expect( context.themesRelDirPosix ).toBe( 'themes' ); + expect( context.themesBaseUrl ).toBe( '/custom/themes' ); + } ); + + test( 'YARD_THEMES_DIR overrides the probe list', () => { + process.env.YARD_THEMES_DIR = 'wp-content/themes'; + clearThemeContextCache(); + + const context = resolve( 'decoy' ); + + expect( context.source ).toBe( 'env' ); + expect( context.themesRelDirPosix ).toBe( 'wp-content/themes' ); + } ); +} ); + +describe( 'default theme selection', () => { + test( 'sage keeps winning when several parents exist', () => { + const context = resolve( 'multi-parent' ); + + expect( context.parents.map( ( theme ) => theme.name ) ).toEqual( [ + 'participatieeiland', + 'sage', + ] ); + expect( context.defaultTheme ).toBe( 'sage' ); + } ); + + test( 'without sage it falls back alphabetically and warns once', () => { + const warn = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + + try { + expect( resolve( 'multi-parent-no-sage' ).defaultTheme ).toBe( + 'alpha' + ); + expect( warn ).toHaveBeenCalledTimes( 1 ); + expect( warn.mock.calls[ 0 ][ 0 ] ).toContain( 'defaultTheme' ); + } finally { + warn.mockRestore(); + } + } ); +} ); + +describe( 'failure handling', () => { + test( 'throws with guidance when nothing resolves', () => { + expect( () => resolve( 'empty' ) ).toThrow( + /Unable to determine project context/ + ); + } ); + + test( 'tryResolveThemeContext returns null instead of throwing', () => { + expect( + tryResolveThemeContext( { cwd: fixture( 'empty' ) } ) + ).toBeNull(); + } ); +} ); + +describe( 'caching', () => { + test( 'repeated calls return the same object', () => { + expect( resolve( 'bedrock' ) ).toBe( resolve( 'bedrock' ) ); + } ); + + test( 'getAllThemeNames reads through the cache', () => { + expect( getAllThemeNames( { cwd: fixture( 'bedrock' ) } ) ).toEqual( [ + 'sage', + 'sage-child', + ] ); + } ); +} ); diff --git a/packages/shared-utils/vitest.config.mjs b/packages/shared-utils/vitest.config.mjs new file mode 100644 index 0000000..770920c --- /dev/null +++ b/packages/shared-utils/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/packages/toolkit/README.md b/packages/toolkit/README.md index 0b97ea9..21b8426 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -46,11 +46,21 @@ Options `brave` mode auto-detects where the command runs: -- **brave-root** — cwd has `web/app/themes/`. Operates on all themes (lint/format globs, build/watch). -- **theme-root** — cwd is a single theme (has `style.css`, no `web/app/themes/`). Operates on that one theme. +- **brave-root** — cwd is the project root and holds a themes directory. Operates on all themes (lint/format globs, build/watch). +- **theme-root** — cwd is a single theme (has `style.css`, no themes directory). Operates on that one theme. Same commands either way. +The themes directory itself is detected too, so Bedrock (`web/app/themes`), classic WordPress (`wp-content/themes`) and custom layouts all work. See [project layout detection](../shared-utils/README.md#project-layout-detection) for the resolution order and the config keys. + +```bash +# Shows the resolved layout: themes directory, docroot, asset base URL, themes with their parent/child relation, and which layer resolved it. +yard-toolkit info + +# 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/actions/info.js b/packages/toolkit/src/actions/info.js new file mode 100644 index 0000000..d591518 --- /dev/null +++ b/packages/toolkit/src/actions/info.js @@ -0,0 +1,46 @@ +/** + * Internal dependencies + */ +import { resolveThemeContext } from '@yardinternet/shared-utils'; +import log from '../utils/logger.js'; + +const SOURCE_LABELS = { + config: 'yard-toolkit.config.json / package.json "yardToolkit"', + env: 'YARD_THEMES_DIR environment variable', + composer: 'composer.json extra.installer-paths', + probe: 'known layout probe', + 'style.css': 'style.css in the current directory', +}; + +/** + * Prints the resolved project layout. First stop when paths do not line up. + */ +export const info = () => { + let context; + + try { + context = resolveThemeContext(); + } catch ( err ) { + log.error( err.message ); + return; + } + + log.info( `Mode: ${ context.mode }` ); + log.info( `Project root: ${ context.projectRoot }` ); + log.info( + `Themes dir: ${ context.themesRelDirPosix } (${ + SOURCE_LABELS[ context.source ] ?? context.source + })` + ); + log.info( `Docroot: ${ context.docRoot ?? 'not detected' }` ); + log.info( `Asset base URL: ${ context.themesBaseUrl }` ); + log.info( `Default theme: ${ context.defaultTheme }` ); + + context.themes.forEach( ( theme ) => { + const relation = theme.isParent + ? 'parent' + : `child of ${ theme.template }`; + + log.info( ` - ${ theme.name } (${ relation })` ); + } ); +}; diff --git a/packages/toolkit/src/config/actions.js b/packages/toolkit/src/config/actions.js index 414a026..7bec762 100644 --- a/packages/toolkit/src/config/actions.js +++ b/packages/toolkit/src/config/actions.js @@ -1,6 +1,8 @@ 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 { info } from '../actions/info.js'; import { lint } from '../actions/lint.js'; export const actions = { @@ -20,4 +22,12 @@ export const actions = { name: 'lint', func: lint, }, + info: { + name: 'info', + func: info, + }, + clean: { + name: 'clean', + func: clean, + }, }; diff --git a/packages/toolkit/src/config/help.js b/packages/toolkit/src/config/help.js index decc7ee..ec06a81 100644 --- a/packages/toolkit/src/config/help.js +++ b/packages/toolkit/src/config/help.js @@ -13,6 +13,8 @@ Actions ${ actions.lint.name } Runs linter for specified filetype ${ actions.watch.name } Runs Vite watcher ${ actions.build.name } Runs Vite build + ${ actions.info.name } Prints the resolved project layout + ${ actions.clean.name } Removes leftover Vite hot files Filetypes ${ filetypes.js.name } JavaScript files (*${ diff --git a/packages/toolkit/src/config/modes.js b/packages/toolkit/src/config/modes.js index 32ee981..91e0296 100644 --- a/packages/toolkit/src/config/modes.js +++ b/packages/toolkit/src/config/modes.js @@ -11,8 +11,11 @@ import { resolveThemeContext } from '@yardinternet/shared-utils'; * resolved when brave-mode globs are actually requested. */ const buildBravePaths = () => { - const { mode } = resolveThemeContext(); - const prefix = mode === 'theme-root' ? '.' : './web/app/themes/**'; + const context = resolveThemeContext(); + const prefix = + context.mode === 'theme-root' + ? '.' + : `./${ context.themesRelDirPosix }/**`; return [ { diff --git a/packages/toolkit/src/utils/get-block-theme-name.js b/packages/toolkit/src/utils/get-block-theme-name.js index 481b77c..475c4a7 100644 --- a/packages/toolkit/src/utils/get-block-theme-name.js +++ b/packages/toolkit/src/utils/get-block-theme-name.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import path from 'path'; - /** * Internal dependencies */ @@ -10,9 +5,6 @@ import { resolveThemeContext } from '@yardinternet/shared-utils'; /** * Returns the owning theme name for a discovered block path, for logging. - * - * - theme-root: the single theme's name. - * - brave-root: parsed from the `web/app/themes//...` block path. */ export const getBlockThemeName = ( blockPath ) => { const context = resolveThemeContext(); @@ -21,5 +13,5 @@ export const getBlockThemeName = ( blockPath ) => { return context.defaultTheme; } - return blockPath.split( path.sep ).at( 3 ); + return context.themeForPath( blockPath )?.name ?? 'unknown'; }; diff --git a/packages/vite-config/README.md b/packages/vite-config/README.md index 193c44b..f68c8ce 100644 --- a/packages/vite-config/README.md +++ b/packages/vite-config/README.md @@ -85,11 +85,19 @@ export default defineConfig( `braveConfig` and `braveBlocksConfig` auto-detect where they run: -- **brave-root** — cwd has `web/app/themes/`. Builds every theme; asset base `/app/themes//public/build/`. -- **theme-root** — cwd is a single theme (has `style.css`, no `web/app/themes/`). Builds that one theme; output to its own `public/`, asset base `/wp-content/themes//public/build/`. +- **brave-root** — cwd is the project root and holds a themes directory. Builds every theme; output to each theme's own `public/`. +- **theme-root** — cwd is a single theme (has `style.css`, no themes directory). Builds that one theme; asset base `/wp-content/themes//public/build/`. Same config file either way. +The themes directory and the asset base URL are both detected — Bedrock yields `/app/themes//public/build/`, classic WordPress `/wp-content/themes//public/build/`. See [project layout detection](../shared-utils/README.md#project-layout-detection). + +### 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-theme-blocks.js b/packages/vite-config/src/configs/brave-theme-blocks.js index 6efa285..befe8bc 100644 --- a/packages/vite-config/src/configs/brave-theme-blocks.js +++ b/packages/vite-config/src/configs/brave-theme-blocks.js @@ -2,7 +2,7 @@ * Vite configuration for Brave block theme development. * * - A Node script runs this config concurrently per theme. - * - Processes `web/app/themes//resources/blocks` directory. + * - Processes each theme's `resources/blocks` directory. * - Uses the theme's public directory for output, in watch and build modes. */ @@ -30,17 +30,20 @@ export const braveBlocksConfig = ( { blockPath } ) => { /** * Block output directory: * - theme-root: the theme's own `public` directory. - * - brave-root: the owning theme's public directory, derived from the - * `web/app/themes//...` block path. + * - brave-root: the public directory of the theme owning the block path. */ + const owningTheme = context.themeForPath( blockPath ); + + if ( ! owningTheme ) { + throw new Error( + `Unable to determine the theme owning block path "${ blockPath }".` + ); + } + const outDir = context.mode === 'theme-root' ? 'public' - : path.join( - 'web/app/themes', - blockPath.split( path.sep ).at( 3 ), - 'public' - ); + : path.join( owningTheme.relDir, 'public' ); const allThemes = getAllThemeNames(); return defineConfig( { diff --git a/packages/vite-config/src/configs/brave.js b/packages/vite-config/src/configs/brave.js index c764e23..0a423cc 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, @@ -63,14 +64,21 @@ export const braveConfig = ( { const themesToProcess = isDev ? allThemes : [ resolvedTheme ]; /** - * Production asset base (URL the built files are served from): - * - brave-root: the theme's Bedrock path. - * - theme-root: a standard WordPress theme path so the theme is portable. + * Production asset base (URL the built files are served from). Derived from + * where the themes directory sits relative to the WordPress docroot, so a + * Bedrock project yields `/app/themes/...` and a classic install + * `/wp-content/themes/...`. */ - const buildBase = - context.mode === 'theme-root' - ? `/wp-content/themes/${ resolvedTheme }/public/build/` - : `/app/themes/${ resolvedTheme }/public/build/`; + const buildBase = `${ context.themesBaseUrl }/${ 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 +158,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 */ @@ -161,9 +166,17 @@ export const braveConfig = ( { context.mode === 'theme-root' ? [ 'resources/views/**/*.blade.php' ] : [ - 'web/app/themes/**/resources/views/**/*.blade.php', + `${ context.themesRelDirPosix }/**/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/src/utils/generate-aliases.js b/packages/vite-config/src/utils/generate-aliases.js index 1682fc4..59f0b72 100644 --- a/packages/vite-config/src/utils/generate-aliases.js +++ b/packages/vite-config/src/utils/generate-aliases.js @@ -30,12 +30,13 @@ export const generateAliases = ( themeNames ) => { } ); /** - * In a theme-root build the shared `@sage` alias (used by starter source for - * imports like `@sage/styles/base/config.css`) has no sage theme to point - * at, so map it to the local resources for backwards compatibility. + * Starter source imports the parent theme's resources as `@sage/...` (e.g. `@sage/styles/base/config.css`). Projects that do not name their parent theme `sage` have nothing for that alias to point at, so it falls back to the default theme's resources. */ - if ( context.mode === 'theme-root' && ! aliases[ '@sage' ] ) { - aliases[ '@sage' ] = path.join( context.themeDir(), 'resources' ); + if ( ! aliases[ '@sage' ] ) { + aliases[ '@sage' ] = path.join( + context.themeDir( context.defaultTheme ), + 'resources' + ); } return aliases; diff --git a/packages/vite-config/tests/generate-aliases.test.js b/packages/vite-config/tests/generate-aliases.test.js new file mode 100644 index 0000000..da8ee3a --- /dev/null +++ b/packages/vite-config/tests/generate-aliases.test.js @@ -0,0 +1,63 @@ +import path from 'path'; +import { afterEach, expect, test } from 'vitest'; +import { + clearThemeContextCache, + getAllThemeNames, +} from '@yardinternet/shared-utils'; + +import { generateAliases } from '../src/utils/generate-aliases.js'; + +const FIXTURES = path.resolve( + import.meta.dirname, + '../../shared-utils/tests/fixtures' +); + +const originalCwd = process.cwd(); + +const useProject = ( ...segments ) => { + process.chdir( path.join( FIXTURES, ...segments ) ); + clearThemeContextCache(); +}; + +afterEach( () => { + process.chdir( originalCwd ); + clearThemeContextCache(); +} ); + +test( 'aliases every theme by name', () => { + useProject( 'multi-parent' ); + + const aliases = generateAliases( getAllThemeNames() ); + + expect( Object.keys( aliases ).sort() ).toEqual( [ + '@idee', + '@participatieeiland', + '@sage', + ] ); + expect( aliases[ '@sage' ] ).toBe( + path.join( process.cwd(), 'web/app/themes/sage/resources' ) + ); +} ); + +test( '@sage falls back to the default theme when no sage theme exists', () => { + useProject( 'configured' ); + + const aliases = generateAliases( getAllThemeNames() ); + + expect( aliases[ '@sage' ] ).toBe( + path.join( process.cwd(), 'src/themes/zulu/resources' ) + ); +} ); + +test( 'theme-root aliases the local resources', () => { + useProject( 'theme-root', 'basis' ); + + const aliases = generateAliases( getAllThemeNames() ); + + expect( aliases[ '@basis' ] ).toBe( + path.join( process.cwd(), 'resources' ) + ); + expect( aliases[ '@sage' ] ).toBe( + path.join( process.cwd(), 'resources' ) + ); +} ); 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..4d1c911 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -5,6 +5,8 @@ export default defineConfig( { projects: [ 'packages/eslint-config/vitest.config.mjs', 'packages/prettier-config/vitest.config.mjs', + 'packages/shared-utils/vitest.config.mjs', + 'packages/vite-config/vitest.config.mjs', 'packages/toolkit/vitest.config.mjs', 'packages/wp-scripts-config/vitest.config.mjs', ],