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 3020d1640fb35d41f3c2c66f9eba95b2e7f01384 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Thu, 8 Oct 2026 15:58:49 +0200 Subject: [PATCH 2/2] feat(shared-utils): detect project layout instead of hardcoding web/app/themes and sage The toolkit assumed themes live in web/app/themes and the parent theme is named sage. Both are now resolved: the themes directory from config, env, composer installer-paths or a probe of known layouts; the parent/child relation from each theme's style.css header; the asset base URL from where the themes directory sits relative to the docroot. The resolver is authored as CommonJS with an ESM facade so the CJS configs can share it without a build step. The toolkit gains an info command that prints what was resolved. --- packages/shared-utils/README.md | 82 ++- packages/shared-utils/package.json | 9 +- packages/shared-utils/src/index.js | 18 +- .../src/resolve-theme-context.cjs | 501 ++++++++++++++++++ .../fixtures/bedrock-custom-dir/composer.json | 9 + .../bedrock-custom-dir/site/index.php | 1 + .../wp-content/themes/main-child/style.css | 5 + .../site/wp-content/themes/main/style.css | 4 + .../tests/fixtures/bedrock/composer.json | 12 + .../tests/fixtures/bedrock/package.json | 3 + .../resources/styles/base/config.css | 1 + .../web/app/themes/sage-child/style.css | 5 + .../resources/scripts/blocks/hero/block.json | 1 + .../sage/resources/styles/base/config.css | 1 + .../bedrock/web/app/themes/sage/style.css | 4 + .../tests/fixtures/bedrock/web/index.php | 1 + .../tests/fixtures/classic/index.php | 1 + .../classic/wp-content/themes/basis/style.css | 4 + .../configured-file/themes/solo/style.css | 4 + .../configured-file/yard-toolkit.config.json | 4 + .../tests/fixtures/configured/package.json | 7 + .../alpha/resources/styles/base/config.css | 1 + .../configured/src/themes/alpha/style.css | 4 + .../zulu/resources/styles/base/config.css | 1 + .../configured/src/themes/zulu/style.css | 4 + .../decoy/web/app/themes/sage/style.css | 4 + .../tests/fixtures/decoy/web/index.php | 1 + .../decoy/wp-content/themes/legacy/style.css | 4 + .../tests/fixtures/empty/readme.md | 1 + .../web/app/themes/alpha/style.css | 4 + .../web/app/themes/zulu/style.css | 4 + .../multi-parent-no-sage/web/index.php | 1 + .../idee/resources/styles/base/config.css | 1 + .../web/app/themes/idee/style.css | 5 + .../resources/styles/base/config.css | 1 + .../app/themes/participatieeiland/style.css | 4 + .../sage/resources/styles/base/config.css | 1 + .../web/app/themes/sage/style.css | 4 + .../tests/fixtures/multi-parent/web/index.php | 1 + .../basis/resources/styles/base/config.css | 1 + .../tests/fixtures/theme-root/basis/style.css | 4 + .../tests/resolve-theme-context.test.js | 190 +++++++ packages/shared-utils/vitest.config.mjs | 9 + packages/toolkit/README.md | 9 +- packages/toolkit/src/actions/info.js | 46 ++ packages/toolkit/src/config/actions.js | 5 + packages/toolkit/src/config/help.js | 1 + packages/toolkit/src/config/modes.js | 7 +- .../toolkit/src/utils/get-block-theme-name.js | 10 +- packages/vite-config/README.md | 6 +- .../src/configs/brave-theme-blocks.js | 19 +- packages/vite-config/src/configs/brave.js | 14 +- .../vite-config/src/utils/generate-aliases.js | 11 +- .../tests/generate-aliases.test.js | 63 +++ vitest.config.mjs | 1 + 55 files changed, 1078 insertions(+), 41 deletions(-) create mode 100644 packages/shared-utils/src/resolve-theme-context.cjs create mode 100644 packages/shared-utils/tests/fixtures/bedrock-custom-dir/composer.json create mode 100644 packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/index.php create mode 100644 packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/wp-content/themes/main-child/style.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock-custom-dir/site/wp-content/themes/main/style.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock/composer.json create mode 100644 packages/shared-utils/tests/fixtures/bedrock/package.json create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/app/themes/sage-child/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/app/themes/sage-child/style.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/app/themes/sage/resources/scripts/blocks/hero/block.json create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/app/themes/sage/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/app/themes/sage/style.css create mode 100644 packages/shared-utils/tests/fixtures/bedrock/web/index.php create mode 100644 packages/shared-utils/tests/fixtures/classic/index.php create mode 100644 packages/shared-utils/tests/fixtures/classic/wp-content/themes/basis/style.css create mode 100644 packages/shared-utils/tests/fixtures/configured-file/themes/solo/style.css create mode 100644 packages/shared-utils/tests/fixtures/configured-file/yard-toolkit.config.json create mode 100644 packages/shared-utils/tests/fixtures/configured/package.json create mode 100644 packages/shared-utils/tests/fixtures/configured/src/themes/alpha/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/configured/src/themes/alpha/style.css create mode 100644 packages/shared-utils/tests/fixtures/configured/src/themes/zulu/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/configured/src/themes/zulu/style.css create mode 100644 packages/shared-utils/tests/fixtures/decoy/web/app/themes/sage/style.css create mode 100644 packages/shared-utils/tests/fixtures/decoy/web/index.php create mode 100644 packages/shared-utils/tests/fixtures/decoy/wp-content/themes/legacy/style.css create mode 100644 packages/shared-utils/tests/fixtures/empty/readme.md create mode 100644 packages/shared-utils/tests/fixtures/multi-parent-no-sage/web/app/themes/alpha/style.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent-no-sage/web/app/themes/zulu/style.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent-no-sage/web/index.php create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/idee/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/idee/style.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/participatieeiland/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/participatieeiland/style.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/sage/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/app/themes/sage/style.css create mode 100644 packages/shared-utils/tests/fixtures/multi-parent/web/index.php create mode 100644 packages/shared-utils/tests/fixtures/theme-root/basis/resources/styles/base/config.css create mode 100644 packages/shared-utils/tests/fixtures/theme-root/basis/style.css create mode 100644 packages/shared-utils/tests/resolve-theme-context.test.js create mode 100644 packages/shared-utils/vitest.config.mjs create mode 100644 packages/toolkit/src/actions/info.js create mode 100644 packages/vite-config/tests/generate-aliases.test.js 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 07ebdae..21b8426 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -46,12 +46,17 @@ 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 ``` 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 8d9ed34..7bec762 100644 --- a/packages/toolkit/src/config/actions.js +++ b/packages/toolkit/src/config/actions.js @@ -2,6 +2,7 @@ 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 = { @@ -21,6 +22,10 @@ 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 2d4eae0..ec06a81 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.info.name } Prints the resolved project layout ${ actions.clean.name } Removes leftover Vite hot files Filetypes 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 7676dcc..f68c8ce 100644 --- a/packages/vite-config/README.md +++ b/packages/vite-config/README.md @@ -85,11 +85,13 @@ 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')`. 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 0b9f71a..0a423cc 100644 --- a/packages/vite-config/src/configs/brave.js +++ b/packages/vite-config/src/configs/brave.js @@ -64,14 +64,12 @@ 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 @@ -168,7 +166,7 @@ export const braveConfig = ( { context.mode === 'theme-root' ? [ 'resources/views/**/*.blade.php' ] : [ - 'web/app/themes/**/resources/views/**/*.blade.php', + `${ context.themesRelDirPosix }/**/resources/views/**/*.blade.php`, ], } ), /** 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/vitest.config.mjs b/vitest.config.mjs index 69a81ff..4d1c911 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/shared-utils/vitest.config.mjs', 'packages/vite-config/vitest.config.mjs', 'packages/toolkit/vitest.config.mjs', 'packages/wp-scripts-config/vitest.config.mjs',