diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd4f843..bca80d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) + +## [v13.34.0-beta](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...v13.34.0-beta) - 2026-09-29 + +The framework's version tracks Laravel's: this beta requires Laravel 13.34. + +### Added +- A third `pollora:make:theme` template, **Magazine** (`magazine` → `pollora/theme-buzz`): a Full Site Editing block theme whose templates, parts and patterns are edited in the Site Editor, next to `default` and `ecommerce`. The missing-theme page and admin notice list it too + +### Fixed +- A Vite script was printed before WordPress's import map, which Firefox and Safari then ignore: any WordPress script module on the page — the navigation block's, the search block's, the image lightbox's — failed on `@wordpress/interactivity was a bare specifier`, so the block did nothing. Chromium tolerates the order, which hid it. In a classic theme the import map is always in the footer, so any theme with a Vite script in the head was affected as soon as an author inserted such a block. The Vite client of the dev server had the same problem +- A block theme's own `404.html` answered with HTTP 200. WordPress core resolves it to `wp-includes/template-canvas.php`, which is never a Blade view, so it always rendered through `FrontendController`'s raw-PHP-template branch — the only branch that never looked at `is_404()`. Measured on a fresh block theme: right content, wrong status +- A real 404 lost its `error404` body class, and every page served by the template hierarchy carried a meaningless one built from its path (`any-no-such-page`). The `WordPressBodyClass` middleware was meant for Laravel routes, which WordPress's own resolution calls a 404, but it only ran on WordPress routes — the `{any}` fallback included — where WordPress's verdict is the right one. So it did the opposite of its job on both sides: a Laravel route (`Route::get('/dashboard/{tab}')`) kept `error404`, `is_404()` true and a "Page not found" title over its 200 response + +### Changed +- Requires Laravel 13.34: `illuminate/*` `^13.34` (was `^13.32`). Measured on `laravel/framework` v13.34.0: the full suite, Pint, PHPStan and Rector pass unchanged +- The `WordPressBodyClass` middleware is replaced by a `RouteMatched` listener, `ApplyApplicationRouteContext`, which runs on every route: a route WordPress answers (`Route::wp()` and the template-hierarchy fallback, both flagged `isWordPressRoute()`) keeps WordPress's classes and verdict untouched; any other route has `is_404()` cleared and its URI segments added as body classes (`dashboard tab-settings`). A middleware could not do this — Laravel routes are not given the WordPress middleware stack +- On the front end and in the admin, a Vite script is enqueued as a WordPress script module (`wp_enqueue_script_module`), so WordPress places it after its import map, as it does its own modules: in the head of a block theme (the footer with `loadInFooter()`), always in the footer of a classic theme. What a module cannot take — `dependencies()`, `localize()`, `inline()` — goes on a classic companion script, `{handle}-data`, which runs before the module. The editor, login screen and Customizer are unchanged ## [v13.32.0-beta.9](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...v13.32.0-beta.9) - 2026-09-28 diff --git a/composer.json b/composer.json index cc5db723..3983876d 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ "require": { "php": "^8.3", "pollora/helper-overrider": "^1.2", - "illuminate/auth": "^13.32", - "illuminate/support": "^13.32", - "illuminate/database": "^13.32", - "illuminate/routing": "^13.32", - "illuminate/console": "^13.32", + "illuminate/auth": "^13.34", + "illuminate/support": "^13.34", + "illuminate/database": "^13.34", + "illuminate/routing": "^13.34", + "illuminate/console": "^13.34", "pollora/abilities": "^1.0", "pollora/ajax": "^1.0", "pollora/entity": "^1.2", @@ -44,7 +44,7 @@ "coduo/php-humanizer": "5.0.3", "nwidart/laravel-modules": "^13.0", "symfony/process": "^7.4 || ^8.0", - "illuminate/config": "^13.32", + "illuminate/config": "^13.34", "laravel/prompts": "^0.3.21" }, "autoload": { @@ -91,6 +91,9 @@ "pestphp/pest-plugin": true, "pollora/helper-overrider": true, "wikimedia/composer-merge-plugin": true + }, + "audit": { + "ignore-abandoned": ["symplify/rule-doc-generator-contracts"] } }, "require-dev": { diff --git a/resources/views/theme-missing.blade.php b/resources/views/theme-missing.blade.php index f33815f4..d955c5da 100644 --- a/resources/views/theme-missing.blade.php +++ b/resources/views/theme-missing.blade.php @@ -128,6 +128,12 @@
{{ $prefix }}php artisan pollora:make:theme my-shop --repository=pollora/theme-apiary
+
+

Start from the magazine theme

+

A Full Site Editing block theme: its templates are edited in the Site Editor.

+
{{ $prefix }}php artisan pollora:make:theme my-journal --repository=pollora/theme-buzz
+
+

The command downloads the theme, fills in its metadata, installs its npm dependencies and builds its assets, then activates it in WordPress. Run it from the project root, then reload this page. diff --git a/src/Asset/Domain/Contracts/ViteManagerInterface.php b/src/Asset/Domain/Contracts/ViteManagerInterface.php index 4199abbc..27521941 100755 --- a/src/Asset/Domain/Contracts/ViteManagerInterface.php +++ b/src/Asset/Domain/Contracts/ViteManagerInterface.php @@ -47,4 +47,9 @@ public function isRunningHot(): bool; * @return string The HTML script tag for Vite client */ public function getViteClientHtml(): string; + + /** + * The URL of the Vite client on the dev server, or an empty string when Vite is not running hot. + */ + public function clientUrl(): string; } diff --git a/src/Asset/Domain/Models/ViteManager.php b/src/Asset/Domain/Models/ViteManager.php index a947fd86..312852c9 100755 --- a/src/Asset/Domain/Models/ViteManager.php +++ b/src/Asset/Domain/Models/ViteManager.php @@ -53,4 +53,12 @@ public function getViteClientHtml(): string { return ''; } + + /** + * Returns an empty string for the Vite client URL (stub). + */ + public function clientUrl(): string + { + return ''; + } } diff --git a/src/Asset/Infrastructure/Services/AssetEnqueuer.php b/src/Asset/Infrastructure/Services/AssetEnqueuer.php index d15cf367..0d9e3989 100755 --- a/src/Asset/Infrastructure/Services/AssetEnqueuer.php +++ b/src/Asset/Infrastructure/Services/AssetEnqueuer.php @@ -22,6 +22,21 @@ */ class AssetEnqueuer { + /** + * Hooks on whose pages WordPress prints script modules, after its import map. + * + * A Vite entry is an ES module. Enqueued as a classic script it was printed in + * the head, before the import map — which Firefox and Safari then ignore, so any + * WordPress module on the page (the navigation block's, for one) failed to + * resolve `@wordpress/interactivity`. Enqueued as a script module, WordPress + * places it itself: after the import map, in the head of a block theme (or its + * footer with loadInFooter()), always in the footer of a classic theme. + * + * The editor, the login screen and the Customizer print no script modules, so + * Vite entries there stay classic scripts. + */ + private const array SCRIPT_MODULE_HOOKS = ['wp_enqueue_scripts', 'admin_enqueue_scripts']; + /** * The asset path or array of paths. * @@ -336,7 +351,7 @@ public function __destruct() $this->loadViteClient($hook); } - resolve(HookAction::class)->add($hook, $this->enqueueStyleOrScript(...), 99); + resolve(HookAction::class)->add($hook, fn () => $this->enqueueStyleOrScript($hook), 99); } } catch (\Throwable $throwable) { Log::error('Error in AssetEnqueuer::__destruct', ['error' => $throwable->getMessage(), 'hooks' => $this->hooks, 'path' => $this->path ?? null]); @@ -346,12 +361,12 @@ public function __destruct() /** * Enqueues all styles and scripts for the current asset. */ - public function enqueueStyleOrScript(): void + public function enqueueStyleOrScript(?string $hook = null): void { $paths = $this->getAssetPaths(); foreach ($paths as $type => $pathList) { foreach ($pathList as $path) { - $this->enqueueAsset((string) $type, $this->forceFullUrl($path)); + $this->enqueueAsset((string) $type, $this->forceFullUrl($path), $hook); } } } @@ -374,6 +389,19 @@ protected function addHook(string $hook): self */ protected function loadViteClient(string $hook): void { + if ($this->printsScriptModules($hook)) { + // The client is a module too: printed ahead of the import map, it would void it. + resolve(HookAction::class)->add($hook, function (): void { + $url = $this->viteManager instanceof ViteManager ? $this->viteManager->clientUrl() : ''; + + if ($url !== '') { + wp_enqueue_script_module('vite-client/'.md5($url), $url); + } + }, 1); + + return; + } + resolve(HookAction::class)->add($hook, function (): void { if ($this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot()) { echo $this->viteManager->getViteClientHtml(); @@ -431,12 +459,14 @@ protected function getAssetPaths(): array * * @throws \InvalidArgumentException When asset type is not supported */ - protected function enqueueAsset(string $type, string $path): void + protected function enqueueAsset(string $type, string $path, ?string $hook = null): void { $handle = $this->useVite && ! $this->viteManager->isRunningHot() ? $this->handle.'/'.sanitize_title(basename($path)) : $this->handle; match ($type) { 'css' => $this->enqueueStyle($path, $handle), - 'js' => $this->enqueueScript($path, $handle), + 'js' => $this->useVite && $this->printsScriptModules($hook) + ? $this->enqueueScriptModule($path, $handle) + : $this->enqueueScript($path, $handle), default => throw new \InvalidArgumentException('Unsupported asset type: '.$type) }; } @@ -467,6 +497,52 @@ protected function enqueueScript(string $path, string $handle): void } } + /** + * Whether a Vite entry enqueued on this hook is printed by WordPress as a script module. + */ + protected function printsScriptModules(?string $hook): bool + { + return in_array($hook, self::SCRIPT_MODULE_HOOKS, true) && function_exists('wp_enqueue_script_module'); + } + + /** + * Enqueues a Vite entry as a WordPress script module. + * + * A module can only depend on modules, and takes no localized data or inline + * script. What the asset declares of those goes on a classic companion script, + * `{handle}-data`, printed in the head: it runs before the module, which the + * browser defers. + */ + protected function enqueueScriptModule(string $path, string $handle): void + { + // null, not false: false appends WordPress's version, and a module is identified by its + // exact URL — a chunk importing the entry back would load a second copy of it. + wp_enqueue_script_module($handle, $path, [], $this->version, ['in_footer' => $this->loadInFooter]); + + resolve(HookFilter::class)->add('wp_script_attributes', fn (array $attributes): array => ($attributes['id'] ?? null) === $handle.'-js-module' + ? [...$attributes, 'crossorigin' => true] + : $attributes); + + $hasInlineContent = ! in_array($this->inlineContent, [null, '', '0'], true); + + if ($this->dependencies === [] && $this->localizationData === [] && ! $hasInlineContent) { + return; + } + + $companion = $handle.'-data'; + // No source: a registered handle that prints only its dependencies and inline data. + wp_register_script($companion, false, $this->dependencies, $this->version, false); + wp_enqueue_script($companion); + + foreach ($this->localizationData as $objectName => $data) { + wp_localize_script($companion, $objectName, $data); + } + + if ($hasInlineContent) { + wp_add_inline_script($companion, $this->inlineContent, $this->inlinePosition); + } + } + /** * Enqueues a CSS file with WordPress. * diff --git a/src/Asset/Infrastructure/Services/ViteManager.php b/src/Asset/Infrastructure/Services/ViteManager.php index 15aa8769..9bd42416 100755 --- a/src/Asset/Infrastructure/Services/ViteManager.php +++ b/src/Asset/Infrastructure/Services/ViteManager.php @@ -99,6 +99,14 @@ public function asset(string $path): string return $this->getViteInstance()->asset($this->container()->getBasePath().$path); } + /** + * The URL of the Vite client on the dev server, or an empty string when Vite is not running hot. + */ + public function clientUrl(): string + { + return $this->isRunningHot() ? $this->getViteInstance()->asset('@vite/client') : ''; + } + /** * Checks if Vite is running in hot module replacement mode. * diff --git a/src/Route/Infrastructure/Listeners/ApplyApplicationRouteContext.php b/src/Route/Infrastructure/Listeners/ApplyApplicationRouteContext.php new file mode 100644 index 00000000..078fa381 --- /dev/null +++ b/src/Route/Infrastructure/Listeners/ApplyApplicationRouteContext.php @@ -0,0 +1,109 @@ +` + * carries `error404` and the document title reads "Page not found", over a + * response that is a perfectly good 200. + * + * WordPress's verdict is only meaningful where WordPress answers: a + * `Route::wp()` route and the template-hierarchy fallback are both flagged as + * WordPress routes and left alone, so a real 404 keeps its `error404` class. + * Every other route gets the 404 state cleared and its own URI segments as body + * classes (`/dashboard/{tab}` → `dashboard tab-settings`). + */ +final class ApplyApplicationRouteContext +{ + public function handle(RouteMatched $event): void + { + $route = $event->route; + + if (! $route instanceof Route || $route->isWordPressRoute()) { + return; + } + + $this->clearNotFoundState(); + + if (function_exists('add_filter')) { + add_filter('body_class', $this->bodyClassCallback($route)); + } + } + + /** + * WordPress's "not found" verdict on the request is meaningless for a URL Laravel owns. + */ + private function clearNotFoundState(): void + { + $query = $GLOBALS['wp_query'] ?? null; + + if (is_object($query) && property_exists($query, 'is_404')) { + $query->is_404 = false; + } + } + + /** + * @return Closure(array): array + */ + private function bodyClassCallback(Route $route): Closure + { + return fn (array $classes): array => array_merge($this->routeTokens($route), $classes); + } + + /** + * @return array + */ + private function routeTokens(Route $route): array + { + $compiled = $route->getCompiled(); + + if (! $compiled || ! method_exists($compiled, 'getTokens')) { + return []; + } + + return array_values(array_filter(array_map( + fn (array $token): string|false => match ($token[0]) { + 'variable' => $this->variableToken($token, $route), + 'text' => $this->sanitizeClass($token[1]), + default => false, + }, + array_reverse($compiled->getTokens()) + ))); + } + + /** + * @param array $token + */ + private function variableToken(array $token, Route $route): string|false + { + if (isset($token[3]) && $route->hasParameter($parameter = $token[3])) { + $value = $route->parameter($parameter); + + return is_string($value) ? sprintf('%s-%s', $parameter, $this->sanitizeClass($value)) : false; + } + + return false; + } + + private function sanitizeClass(string $text): string + { + // A text token keeps the slash that precedes it: "/dashboard". + $text = trim($text, '/'); + + if (function_exists('sanitize_title')) { + return sanitize_title($text); + } + + return strtolower((string) preg_replace('/[^a-zA-Z0-9\-_]/', '-', trim($text))); + } +} diff --git a/src/Route/Infrastructure/Middleware/WordPressBodyClass.php b/src/Route/Infrastructure/Middleware/WordPressBodyClass.php deleted file mode 100644 index 04fcabe4..00000000 --- a/src/Route/Infrastructure/Middleware/WordPressBodyClass.php +++ /dev/null @@ -1,127 +0,0 @@ -route(); - - if ($route instanceof Route && function_exists('add_filter')) { - add_filter('body_class', $this->getBodyClassCallback($route)); - } - - return $next($request); - } - - /** - * Get the callback for modifying body classes. - * - * @param Route $route The current route instance - * @return Closure Callback that filters the body class array - */ - private function getBodyClassCallback(Route $route): Closure - { - return function (array $classes) use ($route): array { - // Don't modify classes for WordPress routes (they handle their own) - if ($route->hasCondition()) { - return $classes; - } - - $tokens = $this->getRouteTokens($route); - - if ($tokens !== []) { - return array_filter( - array_merge($tokens, $classes), - fn ($class): bool => $class !== 'error404' - ); - } - - return $classes; - }; - } - - /** - * Extract route tokens for body classes. - * - * @param Route $route Route instance to inspect - * @return array Array of tokens for body class generation - */ - private function getRouteTokens(Route $route): array - { - $compiled = $route->getCompiled(); - if (! $compiled || ! method_exists($compiled, 'getTokens')) { - return []; - } - - return array_filter(array_map( - fn (array $token): string|false => match ($token[0]) { - 'variable' => $this->handleVariableToken($token, $route), - 'text' => $this->sanitizeClass($token[1]), - default => false, - }, - array_reverse($compiled->getTokens()) - )); - } - - /** - * Handle variable tokens in the route. - * - * @param array $token Token definition from the compiled route - * @param Route $route Current route instance - * @return string|false Sanitized token or false when not applicable - */ - /** - * @param array $token - */ - private function handleVariableToken(array $token, Route $route): string|false - { - if (isset($token[3]) && $route->hasParameter($paramKey = $token[3])) { - $param = $route->parameter($paramKey); - - return is_string($param) ? - sprintf('%s-%s', $paramKey, $this->sanitizeClass($param)) : - false; - } - - return false; - } - - /** - * Sanitize a string for use as a CSS class. - * - * @param string $text Text to sanitize - * @return string Sanitized CSS class value - */ - private function sanitizeClass(string $text): string - { - if (function_exists('sanitize_title')) { - return sanitize_title($text); - } - - // Fallback sanitization - return strtolower((string) preg_replace('/[^a-zA-Z0-9\-_]/', '-', trim($text))); - } -} diff --git a/src/Route/Infrastructure/Providers/RouteServiceProvider.php b/src/Route/Infrastructure/Providers/RouteServiceProvider.php index 8c04b3c5..e48ff9a5 100644 --- a/src/Route/Infrastructure/Providers/RouteServiceProvider.php +++ b/src/Route/Infrastructure/Providers/RouteServiceProvider.php @@ -5,14 +5,16 @@ namespace Pollora\Route\Infrastructure\Providers; use Illuminate\Foundation\Application; +use Illuminate\Routing\Events\RouteMatched; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Pollora\Route\Application\UseCases\BindWordPressParametersUseCase; use Pollora\Route\Application\UseCases\RegisterWordPressTypesUseCase; use Pollora\Route\Domain\Contracts\ConditionResolverInterface; +use Pollora\Route\Domain\Contracts\WordPressRouteInterface; +use Pollora\Route\Infrastructure\Listeners\ApplyApplicationRouteContext; use Pollora\Route\Infrastructure\Middleware\WordPressBindings; -use Pollora\Route\Infrastructure\Middleware\WordPressBodyClass; use Pollora\Route\Infrastructure\Middleware\WordPressHeaders; use Pollora\Route\Infrastructure\Middleware\WordPressShutdown; use Pollora\Route\Infrastructure\Services\Contracts\WordPressConditionManagerInterface; @@ -43,7 +45,6 @@ class RouteServiceProvider extends ServiceProvider public const WORDPRESS_MIDDLEWARE = [ WordPressBindings::class, WordPressHeaders::class, - WordPressBodyClass::class, WordPressShutdown::class, ]; @@ -68,6 +69,8 @@ public function boot(): void $this->registerWpMatchMacro(); $this->registerWpMacro(); + Event::listen(RouteMatched::class, [ApplyApplicationRouteContext::class, 'handle']); + // Register fallback route after modules have loaded their routes. // Two triggers ensure it works with or without the modules system: // 1. Event from ModuleServiceProvider (when modules are present) @@ -210,8 +213,14 @@ protected function bootFallbackRoute(): void { $this->app->instance('route.fallback.registered', true); - Route::any('{any}', [FrontendController::class, 'handle']) + $route = Route::any('{any}', [FrontendController::class, 'handle']) ->where('any', '^(?!api/).*') ->middleware(self::WORDPRESS_MIDDLEWARE); + + // WordPress answers here, so its own verdict on the request (a 404 + // included) stands: see ApplyApplicationRouteContext. + if ($route instanceof WordPressRouteInterface) { + $route->setIsWordPressRoute(true); + } } } diff --git a/src/Route/UI/Http/Controllers/FrontendController.php b/src/Route/UI/Http/Controllers/FrontendController.php index 358e7372..f6e16cea 100644 --- a/src/Route/UI/Http/Controllers/FrontendController.php +++ b/src/Route/UI/Http/Controllers/FrontendController.php @@ -66,7 +66,12 @@ public function handle(Request $request): Response include $templatePath; $content = ob_get_clean(); - return response($content); + // This is how a block theme's own 404.html reaches the page: WordPress + // core resolves it to wp-includes/template-canvas.php, which is never a + // Blade view, so it always falls through to this branch. Without the + // status here, a block theme's 404 template rendered a real "not found" + // page over HTTP 200 — correct content, wrong status. + return response($content, is_404() ? Response::HTTP_NOT_FOUND : Response::HTTP_OK); } // No WordPress template found — fall back to Laravel's error view diff --git a/src/Theme/UI/Console/MakeThemeCommand.php b/src/Theme/UI/Console/MakeThemeCommand.php index 777cdb6d..8985c65e 100755 --- a/src/Theme/UI/Console/MakeThemeCommand.php +++ b/src/Theme/UI/Console/MakeThemeCommand.php @@ -489,6 +489,16 @@ protected function promptForMissingArgumentsUsing(): array protected const TEMPLATES = [ 'default' => 'pollora/theme-default', 'ecommerce' => 'pollora/theme-apiary', + 'magazine' => 'pollora/theme-buzz', + ]; + + /** + * What the prompt says of each built-in template, keyed like TEMPLATES. + */ + protected const TEMPLATE_LABELS = [ + 'default' => 'Default — Basic starter theme', + 'ecommerce' => 'E-commerce — WooCommerce theme (Tailwind CSS, Alpine.js)', + 'magazine' => 'Magazine — Full Site Editing block theme, edited in the Site Editor (Tailwind CSS)', ]; /** @@ -503,8 +513,7 @@ protected function promptForRepository(): ?string $choice = select( label: 'Which theme template would you like to use?', options: [ - 'default' => 'Default — Basic starter theme', - 'ecommerce' => 'E-commerce — WooCommerce theme (Tailwind CSS, Alpine.js)', + ...self::TEMPLATE_LABELS, 'repository' => 'Custom — Download from a GitHub repository', ], default: 'default' diff --git a/src/Theme/UI/Http/MissingThemeNotice.php b/src/Theme/UI/Http/MissingThemeNotice.php index ab75014a..5d38ad28 100644 --- a/src/Theme/UI/Http/MissingThemeNotice.php +++ b/src/Theme/UI/Http/MissingThemeNotice.php @@ -27,15 +27,18 @@ public function render(): void $intro = __('No theme is installed, so the front end of this site cannot be rendered. Generate one from the project root:', 'pollora'); $default = __('Default theme', 'pollora'); $ecommerce = __('E-commerce theme (WooCommerce)', 'pollora'); + $magazine = __('Magazine theme (Full Site Editing)', 'pollora'); printf( - '

%s

%s

%s
%s

%s
%s

', + '

%s

%s

%s
%s

%s
%s

%s
%s

', esc_html__('Pollora: no theme installed', 'pollora'), esc_html($intro), esc_html($default), esc_html($prefix.'php artisan pollora:make:theme my-theme --repository=pollora/theme-default'), esc_html($ecommerce), - esc_html($prefix.'php artisan pollora:make:theme my-shop --repository=pollora/theme-apiary') + esc_html($prefix.'php artisan pollora:make:theme my-shop --repository=pollora/theme-apiary'), + esc_html($magazine), + esc_html($prefix.'php artisan pollora:make:theme my-journal --repository=pollora/theme-buzz') ); } } diff --git a/tests/Feature/Asset/AssetEnqueuerTest.php b/tests/Feature/Asset/AssetEnqueuerTest.php index e4de7b22..431e1c7e 100644 --- a/tests/Feature/Asset/AssetEnqueuerTest.php +++ b/tests/Feature/Asset/AssetEnqueuerTest.php @@ -2,11 +2,14 @@ declare(strict_types=1); +use Brain\Monkey\Functions; use Pollora\Application\Application\Services\ConsoleDetectionService; use Pollora\Application\Domain\Contracts\ConsoleDetectorInterface; use Pollora\Asset\Application\Services\AssetManager; use Pollora\Asset\Infrastructure\Services\AssetEnqueuer; +use Pollora\Asset\Infrastructure\Services\ViteManager; use Pollora\Hook\Domain\Contract\Action as HookAction; +use Pollora\Hook\Domain\Contract\Filter as HookFilter; beforeEach(function (): void { // Bind console detection to always return true (prevents WP calls) @@ -225,6 +228,132 @@ }); }); + describe('Vite entries as script modules', function (): void { + beforeEach(function (): void { + $this->filters = []; + $hookFilter = Mockery::mock(HookFilter::class); + $hookFilter->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($hookFilter): HookFilter { + $this->filters[$hook] = $callback; + + return $hookFilter; + }); + $this->app->instance(HookFilter::class, $hookFilter); + + $this->modules = []; + $this->scripts = []; + $this->registered = []; + Functions\when('wp_enqueue_script_module')->alias(function (string $id, string $src = '', array $deps = [], $version = false, array $args = []): void { + $this->modules[$id] = ['src' => $src, 'deps' => $deps, 'version' => $version, 'args' => $args]; + }); + Functions\when('wp_register_script')->alias(function (string $handle, $src, array $deps = []): void { + $this->registered[$handle] = ['src' => $src, 'deps' => $deps]; + }); + Functions\when('wp_enqueue_script')->alias(function (string $handle, $src = '', array $deps = []): void { + $this->scripts[$handle] = ['src' => $src, 'deps' => $deps]; + }); + Functions\when('sanitize_title')->alias(fn (string $title): string => strtolower(str_replace('.', '-', $title))); + + $this->viteManager = Mockery::mock(ViteManager::class); + $this->viteManager->shouldReceive('isRunningHot')->andReturn(false)->byDefault(); + $this->viteManager->shouldReceive('getAssetUrls')->andReturn([])->byDefault(); + }); + + /** An enqueuer for a built Vite entry, set up the way useVite() would outside the console. */ + function viteEnqueuer(ViteManager $viteManager, array $settings = []): AssetEnqueuer + { + $enqueuer = resolve(AssetEnqueuer::class)->handle('buzz/script'); + + foreach (['useVite' => true, 'viteManager' => $viteManager, 'path' => ['js' => ['https://site.test/build/app-abc.js']], ...$settings] as $property => $value) { + (new ReflectionProperty($enqueuer, $property))->setValue($enqueuer, $value); + } + + return $enqueuer; + } + + it('enqueues the entry as a module on the front end, with no version appended', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules)->toHaveKey('buzz/script/app-abc-js') + ->and($this->modules['buzz/script/app-abc-js']) + ->toMatchArray(['src' => 'https://site.test/build/app-abc.js', 'version' => null, 'args' => ['in_footer' => false]]) + ->and($this->scripts)->toBe([]); + }); + + it('passes loadInFooter() on as in_footer, as WordPress reads it for its own modules', function (): void { + viteEnqueuer($this->viteManager, ['loadInFooter' => true])->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules['buzz/script/app-abc-js']['args'])->toBe(['in_footer' => true]); + }); + + it('enqueues the entry as a module in the admin', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('admin_enqueue_scripts'); + + expect($this->modules)->toHaveKey('buzz/script/app-abc-js'); + }); + + it('keeps a classic script where WordPress prints no modules, such as the editor', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('enqueue_block_editor_assets'); + + expect($this->modules)->toBe([]) + ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js'); + }); + + it('marks the module tag crossorigin, and no other tag', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); + + $filter = $this->filters['wp_script_attributes']; + + expect($filter(['id' => 'buzz/script/app-abc-js-js-module']))->toHaveKey('crossorigin', true) + ->and($filter(['id' => 'something-else-js-module']))->not->toHaveKey('crossorigin'); + }); + + it('puts dependencies, localized data and inline script on a classic companion', function (): void { + $localized = []; + $inline = []; + Functions\when('wp_localize_script')->alias(function (string $handle, string $name) use (&$localized): void { + $localized[$handle][] = $name; + }); + Functions\when('wp_add_inline_script')->alias(function (string $handle, string $code) use (&$inline): void { + $inline[$handle] = $code; + }); + + viteEnqueuer($this->viteManager, [ + 'dependencies' => ['jquery'], + 'localizationData' => ['buzzData' => ['a' => 1]], + 'inlineContent' => 'window.ready = true;', + ])->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules['buzz/script/app-abc-js']['deps'])->toBe([]) + ->and($this->registered['buzz/script/app-abc-js-data'])->toBe(['src' => false, 'deps' => ['jquery']]) + ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js-data') + ->and($localized)->toBe(['buzz/script/app-abc-js-data' => ['buzzData']]) + ->and($inline)->toBe(['buzz/script/app-abc-js-data' => 'window.ready = true;']); + }); + + it('enqueues the Vite client as a module while Vite runs hot', function (): void { + $this->viteManager->shouldReceive('isRunningHot')->andReturn(true); + $this->viteManager->shouldReceive('clientUrl')->andReturn('https://site.test:5173/@vite/client'); + + $callbacks = []; + $hookAction = $this->hookAction; + $hookAction->shouldReceive('add')->andReturnUsing(function (string $hook, callable $callback, int $priority = 10) use (&$callbacks, $hookAction): HookAction { + $callbacks[] = [$hook, $priority, $callback]; + + return $hookAction; + }); + + (function (): void { + $this->loadViteClient('wp_enqueue_scripts'); + })->call(viteEnqueuer($this->viteManager)); + + [$hook, $priority, $callback] = $callbacks[0]; + $callback(); + + expect([$hook, $priority])->toBe(['wp_enqueue_scripts', 1]) + ->and(array_column($this->modules, 'src'))->toBe(['https://site.test:5173/@vite/client']); + }); + }); + describe('full chain', function (): void { it('supports complete fluent configuration', function (): void { $enqueuer = $this->app->make(AssetEnqueuer::class); diff --git a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php index 3496f9cf..ecf67288 100644 --- a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php +++ b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php @@ -73,6 +73,29 @@ expect($response->getContent())->toBe('This is a PHP template'); }); + it("answers 404 for a block theme's own 404.html, resolved through template-canvas.php", function (): void { + // A block theme's 404.html is resolved by WordPress core to a temporary + // wp-includes/template-canvas.php, never a Blade view: get_404_template() + // found a real template, so this never reaches the index-fallback branch. + $templatePath = __DIR__.'/test-template.php'; + Brain\Monkey\Functions\when('wp_using_themes')->justReturn(true); + Brain\Monkey\Functions\when('is_embed')->justReturn(false); + Brain\Monkey\Functions\when('is_404')->justReturn(true); + Brain\Monkey\Functions\when('get_404_template')->justReturn($templatePath); + Brain\Monkey\Functions\when('apply_filters')->alias(fn ($filter, $value) => $value); + + $this->templateFinder->shouldReceive('getViewNameFromPath') + ->with($templatePath) + ->andReturn(null); + + $request = Request::create('/nonexistent'); + $response = $this->controller->handle($request); + + expect($response)->toBeInstanceOf(Response::class); + expect($response->getStatusCode())->toBe(404); + expect($response->getContent())->toBe('This is a PHP template'); + }); + it('returns 404 response when no template found', function (): void { Brain\Monkey\Functions\when('wp_using_themes')->justReturn(true); diff --git a/tests/Feature/Theme/MissingThemeGuidanceTest.php b/tests/Feature/Theme/MissingThemeGuidanceTest.php index f5bb5fd1..1e42292a 100644 --- a/tests/Feature/Theme/MissingThemeGuidanceTest.php +++ b/tests/Feature/Theme/MissingThemeGuidanceTest.php @@ -61,7 +61,8 @@ function viewNotFound(string $view = 'home'): InvalidArgumentException expect($response)->toBeInstanceOf(Response::class) ->and($response->getStatusCode())->toBe(503) ->and($response->getContent())->toContain('pollora/theme-default') - ->and($response->getContent())->toContain('pollora/theme-apiary'); + ->and($response->getContent())->toContain('pollora/theme-apiary') + ->and($response->getContent())->toContain('pollora/theme-buzz'); }); it('leaves the exception alone when a theme is installed', function (): void { @@ -140,7 +141,10 @@ function viewNotFound(string $view = 'home'): InvalidArgumentException expect($output)->toContain('notice-warning') ->and($output)->toContain('no theme installed') - ->and($output)->toContain('pollora:make:theme'); + ->and($output)->toContain('pollora:make:theme') + ->and($output)->toContain('pollora/theme-default') + ->and($output)->toContain('pollora/theme-apiary') + ->and($output)->toContain('pollora/theme-buzz'); }); it('stays silent once a theme is installed', function (): void { diff --git a/tests/Unit/Route/Infrastructure/Listeners/ApplyApplicationRouteContextTest.php b/tests/Unit/Route/Infrastructure/Listeners/ApplyApplicationRouteContextTest.php new file mode 100644 index 00000000..db276739 --- /dev/null +++ b/tests/Unit/Route/Infrastructure/Listeners/ApplyApplicationRouteContextTest.php @@ -0,0 +1,65 @@ +filters = []; + Functions\when('add_filter')->alias(function (string $hook, Closure $callback): void { + $this->filters[$hook] = $callback; + }); + + $GLOBALS['wp_query'] = (object) ['is_404' => true]; + }); + + afterEach(function (): void { + unset($GLOBALS['wp_query']); + }); + + /** A route matched against a request, as the router hands it to RouteMatched. */ + function matchedRoute(string $uri, string $path, bool $wordpress = false): RouteMatched + { + $request = Request::create($path); + $route = (new Route(['GET'], $uri, fn (): string => ''))->setIsWordPressRoute($wordpress); + $route->bind($request); + + return new RouteMatched($route, $request); + } + + it('clears the 404 WordPress gave a URL only Laravel knows', function (): void { + (new ApplyApplicationRouteContext)->handle(matchedRoute('dashboard', '/dashboard')); + + expect($GLOBALS['wp_query']->is_404)->toBeFalse(); + }); + + it('names the body after the route and its parameters, and keeps WordPress classes', function (): void { + (new ApplyApplicationRouteContext)->handle(matchedRoute('dashboard/{tab}', '/dashboard/settings')); + + expect(($this->filters['body_class'])(['wp-theme-buzz'])) + ->toBe(['dashboard', 'tab-settings', 'wp-theme-buzz']); + }); + + it('leaves a WordPress route alone, so a real 404 keeps error404', function (): void { + // Route::wp() routes and the template-hierarchy fallback are both flagged. + (new ApplyApplicationRouteContext)->handle(matchedRoute('{any}', '/no-such-page', wordpress: true)); + + expect($GLOBALS['wp_query']->is_404)->toBeTrue() + ->and($this->filters)->toBe([]); + }); + + it('ignores a route that is not a Pollora route', function (): void { + $request = Request::create('/plain'); + $route = new Illuminate\Routing\Route(['GET'], 'plain', fn (): string => ''); + + (new ApplyApplicationRouteContext)->handle(new RouteMatched($route, $request)); + + expect($GLOBALS['wp_query']->is_404)->toBeTrue() + ->and($this->filters)->toBe([]); + }); +}); diff --git a/tests/Unit/Theme/MakeThemeCommandTemplatesTest.php b/tests/Unit/Theme/MakeThemeCommandTemplatesTest.php new file mode 100644 index 00000000..70969537 --- /dev/null +++ b/tests/Unit/Theme/MakeThemeCommandTemplatesTest.php @@ -0,0 +1,21 @@ + (new ReflectionClassConstant(MakeThemeCommand::class, $name))->getValue(); + + it('offers every built-in template in the prompt, and nothing that has no repository', function () use ($constant): void { + expect(array_keys($constant('TEMPLATE_LABELS')))->toBe(array_keys($constant('TEMPLATES'))); + }); + + it('offers Buzz as the magazine template', function () use ($constant): void { + expect($constant('TEMPLATES'))->toMatchArray([ + 'default' => 'pollora/theme-default', + 'ecommerce' => 'pollora/theme-apiary', + 'magazine' => 'pollora/theme-buzz', + ]); + }); +}); diff --git a/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php b/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php index 884cca5b..9c1bea8f 100644 --- a/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php +++ b/tests/e2e/fixtures/themes/e2e-full/resources/views/layouts/e2e.blade.php @@ -5,7 +5,7 @@ @php(wp_head()) - +
@yield('view')
@php(wp_footer()) diff --git a/tests/e2e/fixtures/themes/e2e-full/routes/web.php b/tests/e2e/fixtures/themes/e2e-full/routes/web.php index 05c7243c..e50179ba 100644 --- a/tests/e2e/fixtures/themes/e2e-full/routes/web.php +++ b/tests/e2e/fixtures/themes/e2e-full/routes/web.php @@ -11,9 +11,12 @@ * e2e-hierarchy-laravel exists in WordPress: neither may render through the hierarchy. */ -$page = static fn (string $by): string => '' +$page = static fn (string $by): string => '' ."
{$by}
"; Route::wp('page', 'e2e-hierarchy-routed', static fn (): Response => response($page('wp'))); Route::get('/e2e-hierarchy-laravel', static fn (): Response => response($page('laravel'))); + +// A URL WordPress knows nothing about: its own resolution calls it a 404. +Route::get('/e2e-laravel-only/{tab}', static fn (): Response => response($page('laravel-only'))); diff --git a/tests/e2e/specs/hierarchy.spec.ts b/tests/e2e/specs/hierarchy.spec.ts index 7a42cf14..eb6113f1 100644 --- a/tests/e2e/specs/hierarchy.spec.ts +++ b/tests/e2e/specs/hierarchy.spec.ts @@ -2,7 +2,7 @@ import { rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; import { activateTheme, restoreSite, seed, type Seeded, themeDir } from '../support/hierarchy'; -import { renderedTemplate } from '../support/site'; +import { homeUrl, renderedTemplate } from '../support/site'; /** * The WordPress template hierarchy, resolved to Blade by the framework, read in the @@ -100,6 +100,34 @@ test.describe('Template hierarchy, with a template for every case', () => { } }); +test.describe('Body classes, where WordPress and Laravel disagree about a URL', () => { + test.beforeAll(() => activateTheme('e2e-full')); + + const bodyClasses = async (page: import('@playwright/test').Page, url: string): Promise => { + await page.goto(url); + + return ((await page.locator('body').getAttribute('class')) ?? '').split(/\s+/); + }; + + test('a 404 keeps the error404 class WordPress gave it', async ({ page }) => { + expect(await bodyClasses(page, seeded.urls.notFound)).toContain('error404'); + }); + + test('a Route::wp() route keeps the classes of what WordPress resolved', async ({ page }) => { + const classes = await bodyClasses(page, seeded.urls.routed); + + expect(classes).toContain('page'); + expect(classes).not.toContain('error404'); + }); + + test('a Laravel route WordPress knows nothing about is not a 404, and is named after its URI', async ({ page }) => { + const classes = await bodyClasses(page, homeUrl('/e2e-laravel-only/settings')); + + expect(classes).toEqual(expect.arrayContaining(['e2e-laravel-only', 'tab-settings'])); + expect(classes).not.toContain('error404'); + }); +}); + test.describe('Template hierarchy, with index alone', () => { test.beforeAll(() => activateTheme('e2e-index')); diff --git a/tests/e2e/specs/script-modules.spec.ts b/tests/e2e/specs/script-modules.spec.ts new file mode 100644 index 00000000..0eda6029 --- /dev/null +++ b/tests/e2e/specs/script-modules.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { runId } from '../support/site'; + +/** + * The active theme's Vite entry and WordPress's own script modules, on one page. + * + * A Vite entry is an ES module. Printed before WordPress's import map, it voids the map + * in Firefox and Safari: every WordPress module on the page — the navigation block's, + * here — then fails on `@wordpress/interactivity`. Chromium tolerates the order, so the + * order itself is asserted from the HTML, in every browser. + */ + +// Named, as the theme may have a navigation block of its own. +const navigation = ''; + +test('the theme entry comes after the import map, and WordPress modules run', async ({ page, requestUtils }) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { title: `E2E script modules ${runId}`, content: navigation, status: 'publish' }, + }); + + try { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(post.link); + const html = await page.content(); + + const importMap = html.search(/]*\btype="importmap"/); + expect(importMap, 'the navigation block puts an import map on the page').toBeGreaterThan(-1); + + const themeEntries = [...html.matchAll(/]*\bsrc="[^"]*\/build\/theme\/[^"]*"[^>]*>/g)]; + test.skip(themeEntries.length === 0, 'the active theme loads no Vite script'); + + for (const entry of themeEntries) { + expect(entry[0], 'the theme entry is a module').toContain('type="module"'); + expect(entry.index, 'the theme entry is printed after the import map').toBeGreaterThan(importMap); + } + + const menu = page.locator('.e2e-script-modules'); + await menu.locator('.wp-block-navigation__responsive-container-open').click(); + await expect(menu.locator('.wp-block-navigation__responsive-container')).toHaveClass(/\bis-menu-open\b/); + expect(errors, 'no uncaught page error').toEqual([]); + } finally { + await requestUtils.rest({ method: 'DELETE', path: `/wp/v2/posts/${post.id}`, params: { force: true } }); + } +});