From bebff9f4f3f59b2a035730e39771d3e57aefeee3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 09:45:06 +0200 Subject: [PATCH 1/9] feat: accept plain .html files as block patterns alongside .blade.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme's resources/views/patterns can now hold a plain .html file: it is registered verbatim, with no compilation step, for a pattern that needs no PHP — such as one exported straight from the block editor. Header parsing is unchanged (WordPress's get_file_data(), extension-agnostic); only the discovery extension filter and the content step branch on the extension. Needed for a WordPress Full Site Editing (block) theme, whose patterns carry no dynamic PHP most of the time. Verified end to end on a throwaway block theme: the .html pattern registers, renders through a real block template, and its {{ }}-looking text is not compiled. --- CHANGELOG.md | 3 + .../Application/Services/PatternService.php | 4 +- .../PatternDataExtractorInterface.php | 7 ++- .../Domain/Support/PatternConstants.php | 21 +++++++ .../WordPressPatternDataExtractor.php | 11 ++++ .../WordPressPatternDataExtractorTest.php | 58 +++++++++++++++++++ tests/Pest.php | 2 +- .../Services/PatternServiceTest.php | 20 +++++++ 8 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd4f843..5063a9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...develop) +### Added +- A theme's `resources/views/patterns` can hold a plain `.html` file alongside `.blade.php` ones. It is registered verbatim, with no compilation step — for a pattern that needs no PHP, such as one exported straight from the block editor. Its header (`Title`, `Slug`, `Categories`…) follows the same fenced-comment convention as a Blade pattern's, `` instead of `{{-- \nTitle: ... \n --}}` + ## [v13.32.0-beta.9](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...v13.32.0-beta.9) - 2026-09-28 ### Added diff --git a/src/BlockPattern/Application/Services/PatternService.php b/src/BlockPattern/Application/Services/PatternService.php index 8a70cb50..5d6c072c 100755 --- a/src/BlockPattern/Application/Services/PatternService.php +++ b/src/BlockPattern/Application/Services/PatternService.php @@ -193,13 +193,13 @@ private function wordPressTheme(ThemeMetadata $theme): object */ private function registerPatternsFromDirectory(string $directory, object $theme): void { - // Get all PHP files in the directory (including subdirectories) + // Get every Blade (`.blade.php`) and plain (`.html`) pattern file, including subdirectories $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { - if ($file->isFile() && $file->getExtension() === 'php') { + if ($file->isFile() && in_array($file->getExtension(), PatternConstants::DISCOVERABLE_EXTENSIONS, true)) { $this->processPatternFile($file->getPathname(), $theme); } } diff --git a/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php b/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php index 64132f65..b233a77e 100755 --- a/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php +++ b/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php @@ -32,10 +32,13 @@ public function extractFromFile(string $file): PatternFileData; public function processData(PatternFileData $fileData, object $theme): array; /** - * Get the rendered content for a pattern. + * Get the content for a pattern. + * + * A `.blade.php` file is compiled and rendered; a `.html` file is read + * back verbatim, with no compilation step. * * @param string $file Path to the pattern file - * @return string|null Rendered content or null if not available + * @return string|null Content or null if not available */ public function getContent(string $file): ?string; } diff --git a/src/BlockPattern/Domain/Support/PatternConstants.php b/src/BlockPattern/Domain/Support/PatternConstants.php index 472be879..95cbdd17 100644 --- a/src/BlockPattern/Domain/Support/PatternConstants.php +++ b/src/BlockPattern/Domain/Support/PatternConstants.php @@ -21,6 +21,9 @@ final class PatternConstants /** * File extension for Blade pattern files. + * + * Compiled and executed through the view engine, so the pattern can hold + * dynamic PHP (`{{ get_bloginfo('name') }}`) alongside its block markup. */ public const string PATTERN_FILE_EXTENSION = '.blade.php'; @@ -29,6 +32,24 @@ final class PatternConstants */ public const string PHP_FILE_EXTENSION = '.php'; + /** + * File extension for plain HTML pattern files. + * + * Used verbatim as block markup — never compiled — for a pattern that + * needs no PHP, such as one exported straight from the block editor. + */ + public const string HTML_FILE_EXTENSION = '.html'; + + /** + * Extensions {@see \SplFileInfo::getExtension()} reports for a pattern + * file the discovery walk should hand to the extractor: `foo.blade.php` + * and `foo.html` both qualify, `foo.blade.php` reporting `php` since + * `getExtension()` only ever returns the last dot-segment. + * + * @var array + */ + public const array DISCOVERABLE_EXTENSIONS = ['php', 'html']; + /** * Default viewport width for patterns when none is specified. */ diff --git a/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php b/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php index b844e2d5..11f77500 100755 --- a/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php +++ b/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php @@ -84,9 +84,20 @@ public function processData(PatternFileData $fileData, object $theme): array /** * {@inheritdoc} + * + * A `.blade.php` file is compiled and executed through the view engine, so + * it can hold dynamic PHP alongside its block markup. A `.html` file is + * used verbatim — never compiled — for a pattern that needs none, such as + * one exported straight from the block editor. */ public function getContent(string $file): ?string { + if (Str::endsWith($file, PatternConstants::HTML_FILE_EXTENSION)) { + $content = @file_get_contents($file); + + return $content === false ? null : $content; + } + $viewName = Str::replaceLast(PatternConstants::PATTERN_FILE_EXTENSION, '', Str::after($file, 'views/')); return View::exists($viewName) ? View::make($viewName)->render() : null; diff --git a/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php b/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php new file mode 100644 index 00000000..deba91ac --- /dev/null +++ b/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php @@ -0,0 +1,58 @@ +themeDir = sys_get_temp_dir().'/pollora-pattern-content-'.uniqid(); + $this->patternsDir = $this->themeDir.'/resources/views/patterns'; + mkdir($this->patternsDir, 0755, true); + + View::addLocation($this->themeDir.'/resources/views'); + + $this->extractor = new WordPressPatternDataExtractor(Mockery::mock(CollectionFactoryInterface::class)); +}); + +afterEach(function (): void { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->themeDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); + } + + rmdir($this->themeDir); +}); + +describe('WordPressPatternDataExtractor::getContent()', function (): void { + it('compiles and renders a .blade.php pattern', function (): void { + file_put_contents( + $this->patternsDir.'/hero.blade.php', + '

{{ 1 + 1 }}

' + ); + + expect($this->extractor->getContent($this->patternsDir.'/hero.blade.php')) + ->toBe('

2

'); + }); + + it('reads a .html pattern back verbatim, with no compilation', function (): void { + file_put_contents( + $this->patternsDir.'/quote.html', + '
{{ 1 + 1 }}
' + ); + + // The literal "{{ 1 + 1 }}" must survive untouched — a .html pattern + // is never handed to the Blade compiler. + expect($this->extractor->getContent($this->patternsDir.'/quote.html')) + ->toBe('
{{ 1 + 1 }}
'); + }); + + it('returns null for a missing .html pattern file', function (): void { + expect($this->extractor->getContent($this->patternsDir.'/missing.html'))->toBeNull(); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 8c7d283e..a5228840 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,7 +26,7 @@ ->afterEach(function (): void { \Brain\Monkey\tearDown(); }) - ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Schedule', 'Feature/Block', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); + ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Schedule', 'Feature/Block', 'Feature/BlockPattern', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php b/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php index fb06f311..06394619 100644 --- a/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php +++ b/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php @@ -150,6 +150,26 @@ function makePatternService(ThemeService $themeService, array &$registered): Pat expect($registered)->toBeEmpty(); }); + it('registers a plain .html pattern file alongside .blade.php ones', function (): void { + $this->themesRoot = makeThemesRoot(['my-theme' => ['hero']]); + file_put_contents( + $this->themesRoot.'/my-theme/resources/views/patterns/quote.html', + '
quote
' + ); + + $themeService = Mockery::mock(ThemeService::class); + $themeService->shouldReceive('theme')->andReturn(new ThemeMetadata('my-theme', $this->themesRoot)); + $themeService->shouldReceive('getParentThemes')->andReturn([]); + + $registered = []; + makePatternService($themeService, $registered)->registerAll(); + + $slugs = array_map(fn (Pattern $pattern): string => $pattern->getSlug(), $registered); + sort($slugs); + + expect($slugs)->toBe(['theme/hero', 'theme/quote.html']); + }); + it('does nothing when the theme has no patterns directory', function (): void { $this->themesRoot = makeThemesRoot(['my-theme' => []]); removeThemesRoot($this->themesRoot.'/my-theme/resources/views/patterns'); From dc5d1f25e13b8cc1f7c73ac811383b19e9c72791 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 10:09:45 +0200 Subject: [PATCH 2/9] fix: a block theme's own 404 page answers HTTP 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress core resolves a block theme's templates/404.html to a temporary wp-includes/template-canvas.php, never a Blade view, so it always rendered through FrontendController's raw-PHP-template branch — the one branch that never checked is_404(). The status is now set the same way the Blade-view branch already does it. Measured building a Full Site Editing theme: the theme's 404.html rendered its content correctly, but the response was HTTP 200. Classic themes unaffected — checked apiary's 404 and home page both still answer as before. --- CHANGELOG.md | 3 +++ .../Http/Controllers/FrontendController.php | 7 +++++- .../Controllers/FrontendControllerTest.php | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5063a9e3..cc694dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A theme's `resources/views/patterns` can hold a plain `.html` file alongside `.blade.php` ones. It is registered verbatim, with no compilation step — for a pattern that needs no PHP, such as one exported straight from the block editor. Its header (`Title`, `Slug`, `Categories`…) follows the same fenced-comment convention as a Blade pattern's, `` instead of `{{-- \nTitle: ... \n --}}` +### Fixed +- 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 + ## [v13.32.0-beta.9](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...v13.32.0-beta.9) - 2026-09-28 ### Added 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/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php index 3496f9cf..2943ff56 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); From 292beb07396f9f7526c86cb492eb3b442942e4c8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 10:14:22 +0200 Subject: [PATCH 3/9] style: satisfy Rector's SimplifyQuoteEscapeRector in the new test --- .../Route/UI/Http/Controllers/FrontendControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php index 2943ff56..ecf67288 100644 --- a/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php +++ b/tests/Feature/Route/UI/Http/Controllers/FrontendControllerTest.php @@ -73,7 +73,7 @@ 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 { + 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. From 580d5164de2748d5a0816e421dc281696c6fdc5d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 12:13:27 +0200 Subject: [PATCH 4/9] fix: keep WordPress's verdict on its own routes, clear it on Laravel's WordPressBodyClass stripped error404 and added path classes, but it only ran on WordPress routes (Route::wp() and the {any} fallback), where WordPress's verdict is right: a real 404 lost error404 and every hierarchy page got an any- class. Laravel routes, the ones it was meant for, never had the middleware and kept error404, is_404() and a 'Page not found' title. Replaced by a RouteMatched listener that leaves WordPress routes alone and, on any other route, clears the 404 state and adds the URI segments as body classes. The fallback route is now flagged isWordPressRoute(). --- CHANGELOG.md | 4 + .../ApplyApplicationRouteContext.php | 109 +++++++++++++++ .../Middleware/WordPressBodyClass.php | 127 ------------------ .../Providers/RouteServiceProvider.php | 15 ++- .../ApplyApplicationRouteContextTest.php | 65 +++++++++ .../resources/views/layouts/e2e.blade.php | 2 +- .../fixtures/themes/e2e-full/routes/web.php | 5 +- tests/e2e/specs/hierarchy.spec.ts | 30 ++++- 8 files changed, 224 insertions(+), 133 deletions(-) create mode 100644 src/Route/Infrastructure/Listeners/ApplyApplicationRouteContext.php delete mode 100644 src/Route/Infrastructure/Middleware/WordPressBodyClass.php create mode 100644 tests/Unit/Route/Infrastructure/Listeners/ApplyApplicationRouteContextTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cc694dd0..4d496749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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 +- 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 ## [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/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/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/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')); From da8c48c7d032704d0950f43cf6e1986389742ccd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 15:13:42 +0200 Subject: [PATCH 5/9] fix: enqueue Vite entries as script modules, after WordPress's import map A Vite entry is an ES module, but it was enqueued as a classic script and printed in the head, before WordPress's import map. Firefox and Safari then ignore the map, so every WordPress module on the page failed on @wordpress/interactivity. In a classic theme the map is always in the footer, so any theme was exposed as soon as a navigation block was used. On wp_enqueue_scripts and admin_enqueue_scripts, a Vite entry (and the dev server's client) is now a script module: WordPress places it as it places its own. Dependencies, localized data and inline script go on a classic companion, {handle}-data. No version is appended: a module is identified by its URL. --- CHANGELOG.md | 4 + .../Domain/Contracts/ViteManagerInterface.php | 5 + src/Asset/Domain/Models/ViteManager.php | 8 ++ .../Infrastructure/Services/AssetEnqueuer.php | 86 +++++++++++- .../Infrastructure/Services/ViteManager.php | 8 ++ tests/Feature/Asset/AssetEnqueuerTest.php | 129 ++++++++++++++++++ tests/e2e/specs/script-modules.spec.ts | 48 +++++++ 7 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/specs/script-modules.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc694dd0..f9ea762a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A theme's `resources/views/patterns` can hold a plain `.html` file alongside `.blade.php` ones. It is registered verbatim, with no compilation step — for a pattern that needs no PHP, such as one exported straight from the block editor. Its header (`Title`, `Slug`, `Categories`…) follows the same fenced-comment convention as a Blade pattern's, `` instead of `{{-- \nTitle: ... \n --}}` ### 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 +### Changed +- 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 ### Added 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/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/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 } }); + } +}); From 3c789be829f540afbf1bac1c624f47f0306d8c57 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 15:58:01 +0200 Subject: [PATCH 6/9] ci: let composer audit pass over the abandoned symplify contracts driftingly/rector-laravel (dev only, 2.6.2 is the latest) requires symplify/rule-doc-generator-contracts, now marked abandoned on Packagist with no replacement. composer audit fails on abandoned packages, so Code Quality failed on every pull request. Only this package is ignored: any other abandoned package, and every security advisory, still fail the job. --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index cc5db723..3113b246 100644 --- a/composer.json +++ b/composer.json @@ -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": { From eab236ade59b0ca320d871b1eae915b9ba8f499f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 16:35:38 +0200 Subject: [PATCH 7/9] revert: drop .html block patterns, before they ship Reverts #354, never released. A theme's static patterns belong in patterns/*.php at its root, which WordPress registers natively (and the Site Editor exports): the .html format duplicated that in a format only Pollora knew, and mixed static markup into resources/views, which is otherwise Blade only. resources/views/patterns keeps .blade.php patterns, for those that need Laravel. This reverts commit 9cf62fc2, reversing changes made to 81336d72. --- CHANGELOG.md | 3 - .../Application/Services/PatternService.php | 4 +- .../PatternDataExtractorInterface.php | 7 +-- .../Domain/Support/PatternConstants.php | 21 ------- .../WordPressPatternDataExtractor.php | 11 ---- .../WordPressPatternDataExtractorTest.php | 58 ------------------- tests/Pest.php | 2 +- .../Services/PatternServiceTest.php | 20 ------- 8 files changed, 5 insertions(+), 121 deletions(-) delete mode 100644 tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d93777..1a534a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...develop) -### Added -- A theme's `resources/views/patterns` can hold a plain `.html` file alongside `.blade.php` ones. It is registered verbatim, with no compilation step — for a pattern that needs no PHP, such as one exported straight from the block editor. Its header (`Title`, `Slug`, `Categories`…) follows the same fenced-comment convention as a Blade pattern's, `` instead of `{{-- \nTitle: ... \n --}}` - ### 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 diff --git a/src/BlockPattern/Application/Services/PatternService.php b/src/BlockPattern/Application/Services/PatternService.php index 5d6c072c..8a70cb50 100755 --- a/src/BlockPattern/Application/Services/PatternService.php +++ b/src/BlockPattern/Application/Services/PatternService.php @@ -193,13 +193,13 @@ private function wordPressTheme(ThemeMetadata $theme): object */ private function registerPatternsFromDirectory(string $directory, object $theme): void { - // Get every Blade (`.blade.php`) and plain (`.html`) pattern file, including subdirectories + // Get all PHP files in the directory (including subdirectories) $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { - if ($file->isFile() && in_array($file->getExtension(), PatternConstants::DISCOVERABLE_EXTENSIONS, true)) { + if ($file->isFile() && $file->getExtension() === 'php') { $this->processPatternFile($file->getPathname(), $theme); } } diff --git a/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php b/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php index b233a77e..64132f65 100755 --- a/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php +++ b/src/BlockPattern/Domain/Contracts/PatternDataExtractorInterface.php @@ -32,13 +32,10 @@ public function extractFromFile(string $file): PatternFileData; public function processData(PatternFileData $fileData, object $theme): array; /** - * Get the content for a pattern. - * - * A `.blade.php` file is compiled and rendered; a `.html` file is read - * back verbatim, with no compilation step. + * Get the rendered content for a pattern. * * @param string $file Path to the pattern file - * @return string|null Content or null if not available + * @return string|null Rendered content or null if not available */ public function getContent(string $file): ?string; } diff --git a/src/BlockPattern/Domain/Support/PatternConstants.php b/src/BlockPattern/Domain/Support/PatternConstants.php index 95cbdd17..472be879 100644 --- a/src/BlockPattern/Domain/Support/PatternConstants.php +++ b/src/BlockPattern/Domain/Support/PatternConstants.php @@ -21,9 +21,6 @@ final class PatternConstants /** * File extension for Blade pattern files. - * - * Compiled and executed through the view engine, so the pattern can hold - * dynamic PHP (`{{ get_bloginfo('name') }}`) alongside its block markup. */ public const string PATTERN_FILE_EXTENSION = '.blade.php'; @@ -32,24 +29,6 @@ final class PatternConstants */ public const string PHP_FILE_EXTENSION = '.php'; - /** - * File extension for plain HTML pattern files. - * - * Used verbatim as block markup — never compiled — for a pattern that - * needs no PHP, such as one exported straight from the block editor. - */ - public const string HTML_FILE_EXTENSION = '.html'; - - /** - * Extensions {@see \SplFileInfo::getExtension()} reports for a pattern - * file the discovery walk should hand to the extractor: `foo.blade.php` - * and `foo.html` both qualify, `foo.blade.php` reporting `php` since - * `getExtension()` only ever returns the last dot-segment. - * - * @var array - */ - public const array DISCOVERABLE_EXTENSIONS = ['php', 'html']; - /** * Default viewport width for patterns when none is specified. */ diff --git a/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php b/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php index 11f77500..b844e2d5 100755 --- a/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php +++ b/src/BlockPattern/Infrastructure/Adapters/WordPressPatternDataExtractor.php @@ -84,20 +84,9 @@ public function processData(PatternFileData $fileData, object $theme): array /** * {@inheritdoc} - * - * A `.blade.php` file is compiled and executed through the view engine, so - * it can hold dynamic PHP alongside its block markup. A `.html` file is - * used verbatim — never compiled — for a pattern that needs none, such as - * one exported straight from the block editor. */ public function getContent(string $file): ?string { - if (Str::endsWith($file, PatternConstants::HTML_FILE_EXTENSION)) { - $content = @file_get_contents($file); - - return $content === false ? null : $content; - } - $viewName = Str::replaceLast(PatternConstants::PATTERN_FILE_EXTENSION, '', Str::after($file, 'views/')); return View::exists($viewName) ? View::make($viewName)->render() : null; diff --git a/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php b/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php deleted file mode 100644 index deba91ac..00000000 --- a/tests/Feature/BlockPattern/WordPressPatternDataExtractorTest.php +++ /dev/null @@ -1,58 +0,0 @@ -themeDir = sys_get_temp_dir().'/pollora-pattern-content-'.uniqid(); - $this->patternsDir = $this->themeDir.'/resources/views/patterns'; - mkdir($this->patternsDir, 0755, true); - - View::addLocation($this->themeDir.'/resources/views'); - - $this->extractor = new WordPressPatternDataExtractor(Mockery::mock(CollectionFactoryInterface::class)); -}); - -afterEach(function (): void { - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($this->themeDir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($files as $file) { - $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); - } - - rmdir($this->themeDir); -}); - -describe('WordPressPatternDataExtractor::getContent()', function (): void { - it('compiles and renders a .blade.php pattern', function (): void { - file_put_contents( - $this->patternsDir.'/hero.blade.php', - '

{{ 1 + 1 }}

' - ); - - expect($this->extractor->getContent($this->patternsDir.'/hero.blade.php')) - ->toBe('

2

'); - }); - - it('reads a .html pattern back verbatim, with no compilation', function (): void { - file_put_contents( - $this->patternsDir.'/quote.html', - '
{{ 1 + 1 }}
' - ); - - // The literal "{{ 1 + 1 }}" must survive untouched — a .html pattern - // is never handed to the Blade compiler. - expect($this->extractor->getContent($this->patternsDir.'/quote.html')) - ->toBe('
{{ 1 + 1 }}
'); - }); - - it('returns null for a missing .html pattern file', function (): void { - expect($this->extractor->getContent($this->patternsDir.'/missing.html'))->toBeNull(); - }); -}); diff --git a/tests/Pest.php b/tests/Pest.php index a5228840..8c7d283e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,7 +26,7 @@ ->afterEach(function (): void { \Brain\Monkey\tearDown(); }) - ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Schedule', 'Feature/Block', 'Feature/BlockPattern', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); + ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Schedule', 'Feature/Block', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php b/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php index 06394619..fb06f311 100644 --- a/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php +++ b/tests/Unit/BlockPattern/Application/Services/PatternServiceTest.php @@ -150,26 +150,6 @@ function makePatternService(ThemeService $themeService, array &$registered): Pat expect($registered)->toBeEmpty(); }); - it('registers a plain .html pattern file alongside .blade.php ones', function (): void { - $this->themesRoot = makeThemesRoot(['my-theme' => ['hero']]); - file_put_contents( - $this->themesRoot.'/my-theme/resources/views/patterns/quote.html', - '
quote
' - ); - - $themeService = Mockery::mock(ThemeService::class); - $themeService->shouldReceive('theme')->andReturn(new ThemeMetadata('my-theme', $this->themesRoot)); - $themeService->shouldReceive('getParentThemes')->andReturn([]); - - $registered = []; - makePatternService($themeService, $registered)->registerAll(); - - $slugs = array_map(fn (Pattern $pattern): string => $pattern->getSlug(), $registered); - sort($slugs); - - expect($slugs)->toBe(['theme/hero', 'theme/quote.html']); - }); - it('does nothing when the theme has no patterns directory', function (): void { $this->themesRoot = makeThemesRoot(['my-theme' => []]); removeThemesRoot($this->themesRoot.'/my-theme/resources/views/patterns'); From 951cba5ebeee57ba9cc5eb0ec869304be639389c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 16:59:18 +0200 Subject: [PATCH 8/9] feat: offer Buzz as the magazine template of pollora:make:theme A third built-in template, next to default and ecommerce: a Full Site Editing block theme. The prompt's labels move to TEMPLATE_LABELS, keyed like TEMPLATES, and a test keeps the two in step. The missing-theme page and admin notice list it too. --- CHANGELOG.md | 3 +++ resources/views/theme-missing.blade.php | 6 ++++++ src/Theme/UI/Console/MakeThemeCommand.php | 13 ++++++++++-- src/Theme/UI/Http/MissingThemeNotice.php | 7 +++++-- .../Theme/MissingThemeGuidanceTest.php | 8 +++++-- .../Theme/MakeThemeCommandTemplatesTest.php | 21 +++++++++++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Theme/MakeThemeCommandTemplatesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a534a21..7168d147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...develop) +### 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 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/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/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/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', + ]); + }); +}); From 9795cb89dc60d741ce847fc0ac8a17aedb4480d7 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 17:42:02 +0200 Subject: [PATCH 9/9] chore: release v13.34.0-beta The framework's version tracks Laravel's, now 13.34: illuminate/* ^13.34. Gathers the [Unreleased] entries under the version. --- CHANGELOG.md | 7 ++++++- composer.json | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7168d147..bca80d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 @@ -16,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/composer.json b/composer.json index 3113b246..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": {