From 3f84b93d6b2d8e742b97b8bf54ba43308395286d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 2 Oct 2026 17:09:56 +0200 Subject: [PATCH] fix: print WordPress 7 block styles in the head of Blade pages --- CHANGELOG.md | 1 + .../WordPressTemplateEnhancement.php | 143 ++++++++++++++++++ .../Providers/RouteServiceProvider.php | 2 + .../WordPressTemplateEnhancementTest.php | 97 ++++++++++++ tests/e2e/specs/styles.spec.ts | 47 ++++++ 5 files changed, 290 insertions(+) create mode 100644 src/Route/Infrastructure/Middleware/WordPressTemplateEnhancement.php create mode 100644 tests/Unit/Route/Infrastructure/Middleware/WordPressTemplateEnhancementTest.php create mode 100644 tests/e2e/specs/styles.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 04944d8f..675afd8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `use_default_wp_theme_directory` key of `config/wordpress.php`. Nothing ever read it: setting it to `true` changed nothing, and themes always live in `themes/` (#297) ### Fixed +- Under WordPress 7, a classic theme's block styles and `global-styles` were printed at the bottom of every page, after the content they style — a flash of unstyled content on slow connections — and the head kept two empty placeholders. WordPress loads those styles on demand at `wp_footer`, then moves them back into the head through its template enhancement output buffer, which only starts when `template-loader.php` includes a template: a Blade page includes none. A `WordPressTemplateEnhancement` middleware now plays the buffer's part on the response — `wp_template_enhancement_output_buffer_started` before the view renders, the `wp_template_enhancement_output_buffer` filter and `wp_finalized_template_enhancement_output_buffer` on the HTML — for `Route::wp()` routes and the template hierarchy, so any plugin built on that filter sees Pollora's pages too. A plain Laravel route that prints `wp_head()`/`wp_footer()` can add the middleware itself - `pollora:install` without a terminal — `--no-interaction`, CI, or `pollora new` driving it — stopped on "Site title is required" unless every option was passed: the prompts it could not show were still required. It now fills what is missing with a working local site — the project name as title (the first label of the `APP_URL` host: under DDEV the directory is always `html`), `admin` at `admin@`, a generated password shown once (also with `--install`), `en_US`, not indexed — and keeps every option it is given - `pollora:status` and the dashboard reported a post type or taxonomy under a slug derived from its class name, ignoring the attribute: `#[PostType('synthese-presse')] class SyntheseDePresse` was listed as `synthese-de-presse`, a post type that does not exist, and its `plural` label was ignored too. Both read the attribute now, through `PostType::resolveSlug()` / `Taxonomy::resolveSlug()`, the rule discovery registers with, so they cannot disagree again (#298) diff --git a/src/Route/Infrastructure/Middleware/WordPressTemplateEnhancement.php b/src/Route/Infrastructure/Middleware/WordPressTemplateEnhancement.php new file mode 100644 index 00000000..087e80ee --- /dev/null +++ b/src/Route/Infrastructure/Middleware/WordPressTemplateEnhancement.php @@ -0,0 +1,143 @@ +` through that filter. Pollora renders Blade views into a + * Laravel response and never includes a template, so the buffer never started: + * those styles stayed at the bottom of every page, after the content they style. + * + * This middleware plays the buffer's part on the response instead of PHP's + * output: it fires `wp_template_enhancement_output_buffer_started` before the + * view renders, then applies the filter and the `wp_finalized_template_enhancement_output_buffer` + * action to the response content. Any plugin using that filter — not only + * style hoisting — now sees Pollora's pages. + * + * @see RouteServiceProvider::WORDPRESS_MIDDLEWARE + * @see wp_start_template_enhancement_output_buffer() + * @see wp_finalize_template_enhancement_output_buffer() + */ +class WordPressTemplateEnhancement +{ + /** + * Handle the incoming request. + * + * @param Request $request The incoming HTTP request + * @param Closure $next The next middleware handler in the pipeline + * @return mixed The HTTP response, its HTML passed through the enhancement filter + */ + public function handle(Request $request, Closure $next): mixed + { + if (! $this->shouldEnhance()) { + return $next($request); + } + + do_action('wp_template_enhancement_output_buffer_started'); + + $response = $next($request); + + if ($this->canEnhance($response)) { + $this->enhance($response); + } + + return $response; + } + + /** + * Whether WordPress wants the template output enhanced. + * + * The answer is WordPress's own, so a site that opted out — to stream its + * responses — keeps them as they are. `wp_styles()` is called first: the + * classic theme hooks are added at `wp_default_styles`, which only fires + * when the styles registry is created. + * + * @return bool True if the response should go through the enhancement filter + */ + private function shouldEnhance(): bool + { + if (! function_exists('wp_should_output_buffer_template_for_enhancement') + || ! function_exists('wp_styles') + || ! function_exists('do_action') + || ! function_exists('apply_filters')) { + return false; + } + + wp_styles(); + + return wp_should_output_buffer_template_for_enhancement(); + } + + /** + * Check if the response holds an HTML document that can be rewritten. + * + * @param mixed $response The response to evaluate + * @return bool True for a buffered HTML response + */ + private function canEnhance(mixed $response): bool + { + if (! $response instanceof SymfonyResponse) { + return false; + } + + if ($response instanceof StreamedResponse || $response instanceof BinaryFileResponse) { + return false; + } + + if ($response->isRedirection() || $response->isInformational() || $response->isEmpty()) { + return false; + } + + $contentType = (string) $response->headers->get('Content-Type', ''); + + return $contentType === '' + || str_contains($contentType, 'text/html') + || str_contains($contentType, 'application/xhtml+xml'); + } + + /** + * Run the response content through the enhancement filter and action. + * + * Like WordPress, a callback that throws leaves the page as it was rendered + * rather than breaking it: the error is reported and the original HTML sent. + * + * @param SymfonyResponse $response The response to rewrite + */ + private function enhance(SymfonyResponse $response): void + { + $content = $response->getContent(); + + if ($content === false || $content === '') { + return; + } + + try { + $enhanced = (string) apply_filters('wp_template_enhancement_output_buffer', $content, $content); + do_action('wp_finalized_template_enhancement_output_buffer', $enhanced); + } catch (\Throwable $throwable) { + report($throwable); + + return; + } + + $response->setContent($enhanced); + } +} diff --git a/src/Route/Infrastructure/Providers/RouteServiceProvider.php b/src/Route/Infrastructure/Providers/RouteServiceProvider.php index e48ff9a5..56e37f9d 100644 --- a/src/Route/Infrastructure/Providers/RouteServiceProvider.php +++ b/src/Route/Infrastructure/Providers/RouteServiceProvider.php @@ -17,6 +17,7 @@ use Pollora\Route\Infrastructure\Middleware\WordPressBindings; use Pollora\Route\Infrastructure\Middleware\WordPressHeaders; use Pollora\Route\Infrastructure\Middleware\WordPressShutdown; +use Pollora\Route\Infrastructure\Middleware\WordPressTemplateEnhancement; use Pollora\Route\Infrastructure\Services\Contracts\WordPressConditionManagerInterface; use Pollora\Route\Infrastructure\Services\Contracts\WordPressTypeResolverInterface; use Pollora\Route\Infrastructure\Services\ExtendedRouter; @@ -46,6 +47,7 @@ class RouteServiceProvider extends ServiceProvider WordPressBindings::class, WordPressHeaders::class, WordPressShutdown::class, + WordPressTemplateEnhancement::class, ]; /** diff --git a/tests/Unit/Route/Infrastructure/Middleware/WordPressTemplateEnhancementTest.php b/tests/Unit/Route/Infrastructure/Middleware/WordPressTemplateEnhancementTest.php new file mode 100644 index 00000000..781dff98 --- /dev/null +++ b/tests/Unit/Route/Infrastructure/Middleware/WordPressTemplateEnhancementTest.php @@ -0,0 +1,97 @@ +middleware = new WordPressTemplateEnhancement; + $this->actions = []; + + Brain\Monkey\Functions\when('wp_styles')->justReturn(); + Brain\Monkey\Functions\when('do_action')->alias(function (string $hook, mixed ...$args): void { + $this->actions[] = $hook; + }); +}); + +describe('when WordPress wants the output enhanced', function (): void { + beforeEach(function (): void { + Brain\Monkey\Functions\when('wp_should_output_buffer_template_for_enhancement')->justReturn(true); + }); + + it('announces the buffer before the view renders', function (): void { + Brain\Monkey\Functions\when('apply_filters')->returnArg(2); + + $this->middleware->handle(Request::create('/'), function (): Response { + expect($this->actions)->toBe(['wp_template_enhancement_output_buffer_started']); + + return new Response(''); + }); + + expect($this->actions)->toBe([ + 'wp_template_enhancement_output_buffer_started', + 'wp_finalized_template_enhancement_output_buffer', + ]); + }); + + it('sends the HTML through the enhancement filter', function (): void { + Brain\Monkey\Functions\when('apply_filters')->alias( + fn (string $hook, string $html): string => $hook === 'wp_template_enhancement_output_buffer' + ? str_replace('', '', $html) + : $html + ); + + $response = $this->middleware->handle( + Request::create('/'), + fn (): Response => new Response('', 200, ['Content-Type' => 'text/html; charset=UTF-8']) + ); + + expect($response->getContent())->toBe(''); + }); + + it('leaves JSON, redirects and streamed responses alone', function (Closure $makeResponse): void { + Brain\Monkey\Functions\expect('apply_filters')->never(); + + $this->middleware->handle(Request::create('/'), $makeResponse); + + expect($this->actions)->toBe(['wp_template_enhancement_output_buffer_started']); + })->with([ + 'json' => [fn (): Response => new Response('{"a":1}', 200, ['Content-Type' => 'application/json'])], + 'redirect' => [fn (): Response => new RedirectResponse('/elsewhere')], + 'streamed' => [fn (): Response => new StreamedResponse(fn (): null => null)], + ]); + + it('keeps the rendered page when a filter callback throws', function (): void { + Brain\Monkey\Functions\when('apply_filters')->alias(function (): never { + throw new RuntimeException('broken optimiser'); + }); + $handler = Mockery::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->once()->with(Mockery::type(RuntimeException::class)); + app()->instance(ExceptionHandler::class, $handler); + + $response = $this->middleware->handle( + Request::create('/'), + fn (): Response => new Response('page') + ); + + expect($response->getContent())->toBe('page'); + }); +}); + +it('does nothing when the site opted out of the buffer', function (): void { + Brain\Monkey\Functions\when('wp_should_output_buffer_template_for_enhancement')->justReturn(false); + Brain\Monkey\Functions\expect('apply_filters')->never(); + + $response = $this->middleware->handle( + Request::create('/'), + fn (): Response => new Response('page') + ); + + expect($response->getContent())->toBe('page') + ->and($this->actions)->toBe([]); +}); diff --git a/tests/e2e/specs/styles.spec.ts b/tests/e2e/specs/styles.spec.ts new file mode 100644 index 00000000..0fb44002 --- /dev/null +++ b/tests/e2e/specs/styles.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { runId } from '../support/site'; + +/** + * Block styles and global styles are printed in the head. + * + * WordPress 7 loads a classic theme's block styles on demand: printed at wp_footer, + * once the page's blocks are known, then moved back into the head by the template + * enhancement output buffer — which WordPress only starts when it includes a template. + * A Blade page includes none, so until the framework played the buffer's part they + * stayed at the bottom of the page, after the content they style, and the head kept + * two empty placeholders. A block theme prints them in the head itself: the assertions + * hold for both. + */ + +// A block whose stylesheet only loads when the block is on the page. +const separator = '
'; + +test('block styles and global styles are in the head, not after the content', async ({ page, requestUtils }) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { title: `E2E styles ${runId}`, content: separator, status: 'publish' }, + }); + + try { + const response = await page.request.get(post.link); + expect(response.status()).toBe(200); + + const html = await response.text(); + const headEnd = html.indexOf(''); + expect(headEnd, 'the page has a head').toBeGreaterThan(-1); + + for (const [name, pattern] of [ + ['global styles', /id=["']global-styles-inline-css["']/], + ['the separator block style', /id=["']wp-block-separator-(?:inline-)?css["']/], + ] as const) { + const position = html.search(pattern); + expect(position, `${name} are on the page`).toBeGreaterThan(-1); + expect(position, `${name} are printed in the head`).toBeLessThan(headEnd); + } + + expect(html, 'no placeholder is left for the hoisted styles').not.toContain('-styles-placeholder-inline-css'); + } finally { + await requestUtils.rest({ method: 'DELETE', path: `/wp/v2/posts/${post.id}`, params: { force: true } }); + } +});