Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bebff9f
feat: accept plain .html files as block patterns alongside .blade.php
ogorzalka Sep 29, 2026
9cf62fc
Merge pull request #354 from Pollora/feature/html-block-patterns
ogorzalka Sep 29, 2026
dc5d1f2
fix: a block theme's own 404 page answers HTTP 404
ogorzalka Sep 29, 2026
292beb0
style: satisfy Rector's SimplifyQuoteEscapeRector in the new test
ogorzalka Sep 29, 2026
81336d7
Merge pull request #355 from Pollora/fix/block-theme-404-status
ogorzalka Sep 29, 2026
580d516
fix: keep WordPress's verdict on its own routes, clear it on Laravel's
ogorzalka Sep 29, 2026
da8c48c
fix: enqueue Vite entries as script modules, after WordPress's import…
ogorzalka Sep 29, 2026
3c789be
ci: let composer audit pass over the abandoned symplify contracts
ogorzalka Sep 29, 2026
bc94702
Merge pull request #358 from Pollora/ci/audit-ignore-abandoned-symplify
ogorzalka Sep 29, 2026
37b4079
Merge branch 'develop' into fix/body-class-catch-all
ogorzalka Sep 29, 2026
0508a3b
Merge branch 'develop' into fix/vite-entries-as-script-modules
ogorzalka Sep 29, 2026
cc31ca4
Merge pull request #356 from Pollora/fix/body-class-catch-all
ogorzalka Sep 29, 2026
daa2985
Merge remote-tracking branch 'origin/develop' into fix/vite-entries-a…
ogorzalka Sep 29, 2026
e618bb2
Merge pull request #357 from Pollora/fix/vite-entries-as-script-modules
ogorzalka Sep 29, 2026
eab236a
revert: drop .html block patterns, before they ship
ogorzalka Sep 29, 2026
287ee37
Merge pull request #359 from Pollora/revert/html-block-patterns
ogorzalka Sep 29, 2026
951cba5
feat: offer Buzz as the magazine template of pollora:make:theme
ogorzalka Sep 29, 2026
bf55aca
Merge pull request #360 from Pollora/feature/buzz-theme-template
ogorzalka Sep 29, 2026
9795cb8
chore: release v13.34.0-beta
ogorzalka Sep 29, 2026
6b173b9
Merge pull request #361 from Pollora/release/v13.34.0-beta
ogorzalka Sep 29, 2026
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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,24 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...develop)
## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop)

## [v13.34.0-beta](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...v13.34.0-beta) - 2026-09-29

The framework's version tracks Laravel's: this beta requires Laravel 13.34.

### Added
- A third `pollora:make:theme` template, **Magazine** (`magazine` → `pollora/theme-buzz`): a Full Site Editing block theme whose templates, parts and patterns are edited in the Site Editor, next to `default` and `ecommerce`. The missing-theme page and admin notice list it too

### Fixed
- A Vite script was printed before WordPress's import map, which Firefox and Safari then ignore: any WordPress script module on the page — the navigation block's, the search block's, the image lightbox's — failed on `@wordpress/interactivity was a bare specifier`, so the block did nothing. Chromium tolerates the order, which hid it. In a classic theme the import map is always in the footer, so any theme with a Vite script in the head was affected as soon as an author inserted such a block. The Vite client of the dev server had the same problem
- A block theme's own `404.html` answered with HTTP 200. WordPress core resolves it to `wp-includes/template-canvas.php`, which is never a Blade view, so it always rendered through `FrontendController`'s raw-PHP-template branch — the only branch that never looked at `is_404()`. Measured on a fresh block theme: right content, wrong status
- A real 404 lost its `error404` body class, and every page served by the template hierarchy carried a meaningless one built from its path (`any-no-such-page`). The `WordPressBodyClass` middleware was meant for Laravel routes, which WordPress's own resolution calls a 404, but it only ran on WordPress routes — the `{any}` fallback included — where WordPress's verdict is the right one. So it did the opposite of its job on both sides: a Laravel route (`Route::get('/dashboard/{tab}')`) kept `error404`, `is_404()` true and a "Page not found" title over its 200 response

### Changed
- Requires Laravel 13.34: `illuminate/*` `^13.34` (was `^13.32`). Measured on `laravel/framework` v13.34.0: the full suite, Pint, PHPStan and Rector pass unchanged
- The `WordPressBodyClass` middleware is replaced by a `RouteMatched` listener, `ApplyApplicationRouteContext`, which runs on every route: a route WordPress answers (`Route::wp()` and the template-hierarchy fallback, both flagged `isWordPressRoute()`) keeps WordPress's classes and verdict untouched; any other route has `is_404()` cleared and its URI segments added as body classes (`dashboard tab-settings`). A middleware could not do this — Laravel routes are not given the WordPress middleware stack
- On the front end and in the admin, a Vite script is enqueued as a WordPress script module (`wp_enqueue_script_module`), so WordPress places it after its import map, as it does its own modules: in the head of a block theme (the footer with `loadInFooter()`), always in the footer of a classic theme. What a module cannot take — `dependencies()`, `localize()`, `inline()` — goes on a classic companion script, `{handle}-data`, which runs before the module. The editor, login screen and Customizer are unchanged

## [v13.32.0-beta.9](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...v13.32.0-beta.9) - 2026-09-28

Expand Down
15 changes: 9 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions resources/views/theme-missing.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@
<pre><code>{{ $prefix }}php artisan pollora:make:theme my-shop --repository=pollora/theme-apiary</code></pre>
</div>

<div class="card">
<h2>Start from the magazine theme</h2>
<p>A Full Site Editing block theme: its templates are edited in the Site Editor.</p>
<pre><code>{{ $prefix }}php artisan pollora:make:theme my-journal --repository=pollora/theme-buzz</code></pre>
</div>

<p class="note">
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.
Expand Down
5 changes: 5 additions & 0 deletions src/Asset/Domain/Contracts/ViteManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions src/Asset/Domain/Models/ViteManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@ public function getViteClientHtml(): string
{
return '';
}

/**
* Returns an empty string for the Vite client URL (stub).
*/
public function clientUrl(): string
{
return '';
}
}
86 changes: 81 additions & 5 deletions src/Asset/Infrastructure/Services/AssetEnqueuer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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]);
Expand All @@ -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);
}
}
}
Expand All @@ -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();
Expand Down Expand Up @@ -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)
};
}
Expand Down Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions src/Asset/Infrastructure/Services/ViteManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
109 changes: 109 additions & 0 deletions src/Route/Infrastructure/Listeners/ApplyApplicationRouteContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Infrastructure\Listeners;

use Closure;
use Illuminate\Routing\Events\RouteMatched;
use Pollora\Route\Infrastructure\Models\Route;

/**
* Gives a route the application owns a neutral WordPress context.
*
* WordPress resolves every request against its own rewrite rules before Laravel
* routes it. A URL that only a Laravel route knows (`Route::get('/dashboard')`)
* therefore comes out of that resolution as a 404: `is_404()` is true, `<body>`
* 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<int, string>): array<int, string>
*/
private function bodyClassCallback(Route $route): Closure
{
return fn (array $classes): array => array_merge($this->routeTokens($route), $classes);
}

/**
* @return array<int, string>
*/
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<int, mixed> $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)));
}
}
Loading
Loading