Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<APP_URL host>`, 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)

Expand Down
143 changes: 143 additions & 0 deletions src/Route/Infrastructure/Middleware/WordPressTemplateEnhancement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Infrastructure\Middleware;

use Closure;
use Illuminate\Http\Request;
use Pollora\Route\Infrastructure\Providers\RouteServiceProvider;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
* Middleware giving Blade responses WordPress's template enhancement output buffer.
*
* Since WordPress 6.9, `template-loader.php` fires `wp_before_include_template`
* just before including the theme template, and `wp_start_template_enhancement_output_buffer()`
* starts an output buffer there. When the page is complete, the buffer runs the
* `wp_template_enhancement_output_buffer` filter over the whole HTML document.
*
* WordPress 7 relies on it for classic themes: block styles load on demand and
* are printed at `wp_footer`, once the blocks of the page are known, then
* `wp_hoist_late_printed_styles()` moves them — `global-styles` included — back
* into the `<head>` 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);
}
}
2 changes: 2 additions & 0 deletions src/Route/Infrastructure/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +47,7 @@ class RouteServiceProvider extends ServiceProvider
WordPressBindings::class,
WordPressHeaders::class,
WordPressShutdown::class,
WordPressTemplateEnhancement::class,
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\Request;
use Pollora\Route\Infrastructure\Middleware\WordPressTemplateEnhancement;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

beforeEach(function (): void {
$this->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('<html><head></head><body></body></html>');
});

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('<head>', '<head><style id="hoisted"></style>', $html)
: $html
);

$response = $this->middleware->handle(
Request::create('/'),
fn (): Response => new Response('<html><head></head><body></body></html>', 200, ['Content-Type' => 'text/html; charset=UTF-8'])
);

expect($response->getContent())->toBe('<html><head><style id="hoisted"></style></head><body></body></html>');
});

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('<html><body>page</body></html>')
);

expect($response->getContent())->toBe('<html><body>page</body></html>');
});
});

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('<html><body>page</body></html>')
);

expect($response->getContent())->toBe('<html><body>page</body></html>')
->and($this->actions)->toBe([]);
});
47 changes: 47 additions & 0 deletions tests/e2e/specs/styles.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!-- wp:separator {"className":"e2e-styles"} --><hr class="wp-block-separator has-alpha-channel-opacity e2e-styles"/><!-- /wp: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('</head>');
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 } });
}
});
Loading