From 60dabdf5a92b9dd2f0f8fa017b0a0bed9ec8316b Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 15:31:37 +0200 Subject: [PATCH 1/9] fix: pollora:status reports the slug the attribute registers Closes #298. The slug rule moves to PostType::resolveSlug() and Taxonomy::resolveSlug(), used by discovery and by the status collector alike. --- CHANGELOG.md | 3 ++ src/Attributes/PostType.php | 15 +++++++ src/Attributes/Taxonomy.php | 14 ++++++ .../Domain/Services/SystemInfoCollector.php | 43 +++++++++++++++---- .../Services/PostTypeDiscovery.php | 20 +-------- .../Services/TaxonomyDiscovery.php | 20 +-------- .../Unit/Attributes/PostTypeAttributeTest.php | 10 +++++ .../Unit/Attributes/TaxonomyAttributeTest.php | 6 +++ .../Dashboard/SystemInfoCollectorTest.php | 20 +++++++++ 9 files changed, 105 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11678d7..a7e2b19d 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.34.0...develop) +### Fixed +- `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) + ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 The first stable release since v13.4.4, on Laravel 13.34. It gathers every change from v13.32.0-beta to v13.34.0-beta.2 below; coming from 13.4, read those entries, or run Nectar's `upgrade-pollora-v13-32` prompt ([full comparison](https://github.com/Pollora/framework/compare/v13.4.4...v13.34.0)). diff --git a/src/Attributes/PostType.php b/src/Attributes/PostType.php index 17cc4d9d..80ba791f 100644 --- a/src/Attributes/PostType.php +++ b/src/Attributes/PostType.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes; use Attribute; +use Illuminate\Support\Str; /** * PostType Attribute @@ -38,4 +39,18 @@ public function __construct( ) { // No validation here - will be handled by PostTypeDiscovery } + + /** + * The slug WordPress registers the post type under. + * + * The explicit slug when the attribute has one; otherwise the class name in + * kebab-case, cut to 20 characters. Discovery registers with it and + * `pollora:status` reports it, so the two cannot disagree. + * + * @param string $className The class carrying the attribute + */ + public function resolveSlug(string $className): string + { + return $this->slug ?? substr(Str::kebab(class_basename($className)), 0, 20); + } } diff --git a/src/Attributes/Taxonomy.php b/src/Attributes/Taxonomy.php index ee62c6fe..c301d052 100644 --- a/src/Attributes/Taxonomy.php +++ b/src/Attributes/Taxonomy.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes; use Attribute; +use Illuminate\Support\Str; /** * Attribute for defining custom taxonomies. @@ -49,4 +50,17 @@ public function __construct( public ?string $textDomain = null ) {} + /** + * The slug WordPress registers the taxonomy under. + * + * The explicit slug when the attribute has one; otherwise the class name in + * kebab-case, cut to 20 characters. Discovery registers with it and + * `pollora:status` reports it, so the two cannot disagree. + * + * @param string $className The class carrying the attribute + */ + public function resolveSlug(string $className): string + { + return $this->slug ?? substr(Str::kebab(class_basename($className)), 0, 20); + } } diff --git a/src/Dashboard/Domain/Services/SystemInfoCollector.php b/src/Dashboard/Domain/Services/SystemInfoCollector.php index f23775ac..5d8ae3c6 100644 --- a/src/Dashboard/Domain/Services/SystemInfoCollector.php +++ b/src/Dashboard/Domain/Services/SystemInfoCollector.php @@ -5,6 +5,8 @@ namespace Pollora\Dashboard\Domain\Services; use Nwidart\Modules\Contracts\RepositoryInterface; +use Pollora\Attributes\PostType; +use Pollora\Attributes\Taxonomy; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Support\Domain\StringHelper; use Pollora\VersionCheck\Domain\Services\VersionComparator; @@ -212,8 +214,9 @@ private function collectPostTypeInfo(): array } $class = $item['class']; - $slug = StringHelper::kebab(class_basename($class)); - $label = $this->getPostTypeLabel($slug, $class); + $attribute = $this->attributeOf($class, PostType::class); + $slug = $attribute?->resolveSlug($class) ?? StringHelper::kebab(class_basename($class)); + $label = $this->getPostTypeLabel($slug, $class, $attribute?->plural); $result[] = [ 'class' => $class, @@ -243,8 +246,9 @@ private function collectTaxonomyInfo(): array } $class = $item['class']; - $slug = StringHelper::kebab(class_basename($class)); - $label = $this->getTaxonomyLabel($slug, $class); + $attribute = $this->attributeOf($class, Taxonomy::class); + $slug = $attribute?->resolveSlug($class) ?? StringHelper::kebab(class_basename($class)); + $label = $this->getTaxonomyLabel($slug, $class, $attribute?->plural); $result[] = [ 'class' => $class, @@ -259,7 +263,30 @@ private function collectTaxonomyInfo(): array } } - private function getPostTypeLabel(string $slug, string $class): string + /** + * The registration attribute a discovered class carries, when it can be read. + * + * Its slug is the one WordPress registered, which the class name alone + * does not give: `#[PostType('synthese-presse')] class SyntheseDePresse` + * is `synthese-presse`, not `synthese-de-presse`. + * + * @template T of object + * + * @param class-string $attributeClass + * @return T|null + */ + private function attributeOf(string $class, string $attributeClass): ?object + { + if (! class_exists($class)) { + return null; + } + + $attributes = (new \ReflectionClass($class))->getAttributes($attributeClass); + + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + private function getPostTypeLabel(string $slug, string $class, ?string $plural = null): string { if (function_exists('get_post_type_object')) { $object = get_post_type_object($slug); @@ -269,10 +296,10 @@ private function getPostTypeLabel(string $slug, string $class): string } } - return StringHelper::headline(class_basename($class)); + return $plural ?? StringHelper::headline(class_basename($class)); } - private function getTaxonomyLabel(string $slug, string $class): string + private function getTaxonomyLabel(string $slug, string $class, ?string $plural = null): string { if (function_exists('get_taxonomy')) { $object = get_taxonomy($slug); @@ -282,7 +309,7 @@ private function getTaxonomyLabel(string $slug, string $class): string } } - return StringHelper::headline(class_basename($class)); + return $plural ?? StringHelper::headline(class_basename($class)); } /** diff --git a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php index 2e5fbee1..0095dd91 100644 --- a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php +++ b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php @@ -222,7 +222,7 @@ private function processPostType(string $className, ?ReflectionCacheInterface $r */ private function buildBaseConfiguration(string $className, PostType $postType): PostTypeConfiguration { - $slug = $this->generateSlug($className, $postType->slug); + $slug = $postType->resolveSlug($className); $singular = $this->generateSingular($className, $postType->singular); $plural = $this->generatePlural($postType->plural, $singular); $textDomain = $postType->textDomain ?? 'pollora'; @@ -365,24 +365,6 @@ private function processAdditionalArgs(string $className, PostTypeConfiguration } } - /** - * Generate a post type slug from class name and attribute value. - * - * @param string $className The class name - * @param string|null $attributeSlug The slug from the attribute - * @return string The generated slug - */ - private function generateSlug(string $className, ?string $attributeSlug): string - { - if ($attributeSlug !== null) { - return $attributeSlug; - } - - $slug = Str::kebab(class_basename($className)); - - return substr($slug, 0, 20); - } - /** * Generate a singular name from class name and attribute value. * diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php index fdca1dc5..2cb6ca82 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php @@ -226,7 +226,7 @@ private function processTaxonomy(string $className, ?ReflectionCacheInterface $r */ private function buildBaseConfiguration(string $className, Taxonomy $taxonomy): TaxonomyConfiguration { - $slug = $this->generateSlug($className, $taxonomy->slug); + $slug = $taxonomy->resolveSlug($className); $singular = $this->generateSingular($className, $taxonomy->singular); $plural = $this->generatePlural($taxonomy->plural, $singular); $objectType = $taxonomy->objectType ?? ['post']; @@ -370,24 +370,6 @@ private function processAdditionalArgs(string $className, TaxonomyConfiguration } } - /** - * Generate a taxonomy slug from class name and attribute value. - * - * @param string $className The class name - * @param string|null $attributeSlug The slug from the attribute - * @return string The generated slug - */ - private function generateSlug(string $className, ?string $attributeSlug): string - { - if ($attributeSlug !== null) { - return $attributeSlug; - } - - $slug = Str::kebab(class_basename($className)); - - return substr($slug, 0, 20); - } - /** * Generate a singular name from class name and attribute value. * diff --git a/tests/Unit/Attributes/PostTypeAttributeTest.php b/tests/Unit/Attributes/PostTypeAttributeTest.php index 7be03b79..2c0cd6b9 100644 --- a/tests/Unit/Attributes/PostTypeAttributeTest.php +++ b/tests/Unit/Attributes/PostTypeAttributeTest.php @@ -332,3 +332,13 @@ public function getProductDetails(): array // AdminCol with any values expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class); }); + +test('resolveSlug returns the explicit slug', function (): void { + expect((new PostType('synthese-presse'))->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse')) + ->toBe('synthese-presse'); +}); + +test('resolveSlug derives the slug from the class name, cut to 20 characters', function (): void { + expect((new PostType)->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse'))->toBe('synthese-de-presse') + ->and((new PostType)->resolveSlug('App\\VeryLongPostTypeClassName'))->toBe('very-long-post-type-'); +}); diff --git a/tests/Unit/Attributes/TaxonomyAttributeTest.php b/tests/Unit/Attributes/TaxonomyAttributeTest.php index f06c627a..66edb2fb 100644 --- a/tests/Unit/Attributes/TaxonomyAttributeTest.php +++ b/tests/Unit/Attributes/TaxonomyAttributeTest.php @@ -5,6 +5,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Mockery as m; +use Pollora\Attributes\Taxonomy; use Pollora\Attributes\Taxonomy\AllowHierarchy; use Pollora\Attributes\Taxonomy\Args; use Pollora\Attributes\Taxonomy\CheckedOntop; @@ -285,3 +286,8 @@ function testMethodAttribute(string $attributeName, string $argName, string $met ->toHaveKey('name') ->toHaveKey('singular_name'); }); + +test('resolveSlug returns the explicit slug, or the class name in kebab-case', function (): void { + expect((new Taxonomy('theme-presse'))->resolveSlug('App\\Cms\\Taxonomies\\ThemeDePresse'))->toBe('theme-presse') + ->and((new Taxonomy)->resolveSlug('App\\Cms\\Taxonomies\\ThemeDePresse'))->toBe('theme-de-presse'); +}); diff --git a/tests/Unit/Dashboard/SystemInfoCollectorTest.php b/tests/Unit/Dashboard/SystemInfoCollectorTest.php index 0a5b8bdc..d499d1e8 100644 --- a/tests/Unit/Dashboard/SystemInfoCollectorTest.php +++ b/tests/Unit/Dashboard/SystemInfoCollectorTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Pollora\Attributes\PostType; +use Pollora\Attributes\Taxonomy; use Pollora\Dashboard\Domain\Services\SystemInfoCollector; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; @@ -14,6 +16,12 @@ use Spatie\StructureDiscoverer\Cache\LaravelDiscoverCacheDriver; use Spatie\StructureDiscoverer\Cache\NullDiscoverCacheDriver; +#[PostType('synthese-presse', singular: 'Synthèse de presse', plural: 'Synthèses de presse')] +class SyntheseDePresse {} + +#[Taxonomy('theme-presse', singular: 'Thème de presse', plural: 'Thèmes de presse')] +class ThemeDePresse {} + function createCollector( ?VersionComparator $comparator = null, ?DiscoveryManager $manager = null, @@ -140,6 +148,18 @@ function createCollector( expect($info['post_types']['items'][0]['slug'])->toBe('project'); }); + it('reports the slug and labels of the #[PostType] attribute, not the class name', function (): void { + $manager = Mockery::mock(DiscoveryManager::class); + $manager->shouldReceive('getDiscoveredItems')->with('post_types')->andReturn([['class' => SyntheseDePresse::class]]); + $manager->shouldReceive('getDiscoveredItems')->with('taxonomies')->andReturn([['class' => ThemeDePresse::class]]); + $manager->shouldReceive('getDiscoveredItems')->withAnyArgs()->andReturn([]); + + $info = createCollector(manager: $manager)->collectDiscoveryInfo(); + + expect($info['post_types']['items'][0])->toMatchArray(['slug' => 'synthese-presse', 'label' => 'Synthèses de presse']) + ->and($info['taxonomies']['items'][0])->toMatchArray(['slug' => 'theme-presse', 'label' => 'Thèmes de presse']); + }); + it('collects hook counts by type', function (): void { $manager = Mockery::mock(DiscoveryManager::class); $manager->shouldReceive('getDiscoveredItems') From 3b04c3da45eab3fdedf2a72aee6f324309b2fb99 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 15:32:17 +0200 Subject: [PATCH 2/9] chore: remove the unread use_default_wp_theme_directory key --- CHANGELOG.md | 3 +++ config/wordpress.php | 9 --------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11678d7..e6142f8d 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.34.0...develop) +### Removed +- 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) + ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 The first stable release since v13.4.4, on Laravel 13.34. It gathers every change from v13.32.0-beta to v13.34.0-beta.2 below; coming from 13.4, read those entries, or run Nectar's `upgrade-pollora-v13-32` prompt ([full comparison](https://github.com/Pollora/framework/compare/v13.4.4...v13.34.0)). diff --git a/config/wordpress.php b/config/wordpress.php index 88c67e7f..be09c46c 100644 --- a/config/wordpress.php +++ b/config/wordpress.php @@ -49,15 +49,6 @@ 'is_subpage' => ['subpage', 'subpageof'], ], - /** - * Theme configuration. - * - * Controls whether Pollora framework should use the default WordPress theme directory. - * When enabled (true), Pollora will use the default WordPress theme directory. - * When disabled (false), Pollora will use the custom theme directory (./themes). - */ - 'use_default_wp_theme_directory' => false, - /** * Mail handling configuration. * From 0389f0d3002c037898743836f15fc81f4736e846 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 15:50:51 +0200 Subject: [PATCH 3/9] fix: pollora:install completes without a terminal Missing options get working defaults when the command cannot prompt; a generated admin password is shown once. --- CHANGELOG.md | 3 + .../Installation/DTO/InstallationConfig.php | 38 +++++++++++ .../Commands/LaunchPadInstallCommand.php | 28 +++++--- .../Commands/LaunchPadInstallCommandTest.php | 67 +++++++++++++++++-- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11678d7..f54ced96 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.34.0...develop) +### Fixed +- `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 directory as title, `admin` at `admin@`, a generated password shown once (also with `--install`), `en_US`, not indexed — and keeps every option it is given + ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 The first stable release since v13.4.4, on Laravel 13.34. It gathers every change from v13.32.0-beta to v13.34.0-beta.2 below; coming from 13.4, read those entries, or run Nectar's `upgrade-pollora-v13-32` prompt ([full comparison](https://github.com/Pollora/framework/compare/v13.4.4...v13.34.0)). diff --git a/src/Services/WordPress/Installation/DTO/InstallationConfig.php b/src/Services/WordPress/Installation/DTO/InstallationConfig.php index f88a7bfe..e0a39f95 100755 --- a/src/Services/WordPress/Installation/DTO/InstallationConfig.php +++ b/src/Services/WordPress/Installation/DTO/InstallationConfig.php @@ -4,6 +4,7 @@ namespace Pollora\Services\WordPress\Installation\DTO; +use Illuminate\Support\Str; use Pollora\Services\WordPress\Installation\LanguageService; use function Laravel\Prompts\confirm; @@ -88,4 +89,41 @@ public static function fromPrompts( ), ); } + + /** + * Create configuration for an install that cannot prompt. + * + * Without a terminal — `--no-interaction`, CI, or `pollora new` driving the + * command — prompts cannot be answered, and a required one used to abort the + * install on "Site title is required". Every option given is kept; the rest + * gets a value that makes a working local site: the project directory as + * title, `admin` on the site's own domain, a generated password, `en_US`, + * not indexed. + * + * @param string $projectName Directory name of the project, for the title + * @param string|null $siteUrl The application URL, for the admin email domain + */ + public static function withDefaults( + string $projectName, + ?string $siteUrl, + ?string $title = null, + ?string $description = null, + ?string $adminUser = null, + ?string $adminEmail = null, + ?string $adminPassword = null, + ?string $locale = null, + ?bool $isPublic = null, + ): self { + $host = is_string($siteUrl) ? parse_url($siteUrl, PHP_URL_HOST) : null; + + return new self( + title: $title ?? Str::headline($projectName), + description: $description ?? '', + adminUser: $adminUser ?? 'admin', + adminEmail: $adminEmail ?? 'admin@'.(is_string($host) && str_contains($host, '.') ? $host : 'example.com'), + adminPassword: $adminPassword ?? Str::password(20, symbols: false), + locale: $locale ?? 'en_US', + isPublic: $isPublic ?? false, + ); + } } diff --git a/src/WordPress/Commands/LaunchPadInstallCommand.php b/src/WordPress/Commands/LaunchPadInstallCommand.php index 05c6dc59..3c7d3c6b 100644 --- a/src/WordPress/Commands/LaunchPadInstallCommand.php +++ b/src/WordPress/Commands/LaunchPadInstallCommand.php @@ -15,6 +15,7 @@ use function Laravel\Prompts\error; use function Laravel\Prompts\info; +use function Laravel\Prompts\warning; #[Description('Install and configure WordPress')] #[Signature('pollora:install @@ -83,18 +84,27 @@ private function installWordPress(bool $silent = false): void info('Starting WordPress installation...'); } - $config = InstallationConfig::fromPrompts( - title: $this->option('title'), - description: $this->option('description'), - adminUser: $this->option('admin-user'), - adminEmail: $this->option('admin-email'), - adminPassword: $this->option('admin-password'), - locale: $this->option('locale'), - isPublic: $this->option('public') !== null ? filter_var($this->option('public'), FILTER_VALIDATE_BOOLEAN) : null, - ); + $options = [ + 'title' => $this->option('title'), + 'description' => $this->option('description'), + 'adminUser' => $this->option('admin-user'), + 'adminEmail' => $this->option('admin-email'), + 'adminPassword' => $this->option('admin-password'), + 'locale' => $this->option('locale'), + 'isPublic' => $this->option('public') !== null ? filter_var($this->option('public'), FILTER_VALIDATE_BOOLEAN) : null, + ]; + + $config = $this->input->isInteractive() + ? InstallationConfig::fromPrompts(...$options) + : InstallationConfig::withDefaults(basename(base_path()), config('app.url'), ...$options); $this->installationService->install($config); + if ($options['adminPassword'] === null && ! $this->input->isInteractive()) { + // Generated here and stored hashed: this is the only time it is shown. + warning(sprintf('Admin account: %s / %s — generated, shown only once.', $config->adminUser, $config->adminPassword)); + } + $this->runMigrations(); $this->installTheme(); diff --git a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php index 0e9e039e..772d84e5 100644 --- a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php +++ b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use Illuminate\Config\Repository; use Illuminate\Console\Application; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; use Pollora\Services\WordPress\Installation\DatabaseService; +use Pollora\Services\WordPress\Installation\DTO\InstallationConfig; use Pollora\Services\WordPress\Installation\InstallationService; use Pollora\WordPress\Commands\LaunchPadInstallCommand; use Symfony\Component\Console\Input\ArrayInput; @@ -18,13 +20,16 @@ * migrate are replaced by commands recording the input they receive. * * @param array $parameters - * @return array{exit: int, theme: array|null, migrate: array|null} + * @return array{exit: int, theme: array|null, migrate: array|null, config: InstallationConfig|null, output: string} */ function runInstallCommand(array $parameters, bool $interactive, int $migrateExit = 0): array { + $config = null; $installation = Mockery::mock(InstallationService::class); $installation->shouldReceive('isInstalled')->andReturn(false); - $installation->shouldReceive('install')->once(); + $installation->shouldReceive('install')->once()->andReturnUsing(function (InstallationConfig $given) use (&$config): void { + $config = $given; + }); $database = Mockery::mock(DatabaseService::class); $database->shouldReceive('isConfigured')->andReturn(true); @@ -74,7 +79,13 @@ public function isLocal(): bool { return false; } + + public function basePath(string $path = ''): string + { + return '/srv/acme-site'.($path !== '' ? '/'.$path : ''); + } }; + $container->instance('config', new Repository(['app' => ['url' => 'https://acme-site.ddev.site']])); // handleError() resolves app() to check the environment $previousContainer = Container::getInstance(); Container::setInstance($container); @@ -90,12 +101,13 @@ public function isLocal(): bool $input->setInteractive($interactive); try { - $exit = $application->find('pollora:install')->run($input, new BufferedOutput); + $output = new BufferedOutput; + $exit = $application->find('pollora:install')->run($input, $output); } finally { Container::setInstance($previousContainer); } - return ['exit' => $exit, 'theme' => $recorder->received, 'migrate' => $migrate->received]; + return ['exit' => $exit, 'theme' => $recorder->received, 'migrate' => $migrate->received, 'config' => $config, 'output' => $output->fetch()]; } describe('pollora:install theme generation', function (): void { @@ -163,3 +175,50 @@ public function isLocal(): bool ->and($result['theme'])->toBeNull(); }); }); + +describe('pollora:install without prompts', function (): void { + beforeEach(function (): void { + Brain\Monkey\Functions\when('admin_url')->justReturn('https://acme-site.ddev.site/wp-admin/'); + }); + + it('installs with working defaults instead of failing on the first required prompt', function (): void { + // `pollora new --ddev --no-interaction` ran it with no option and no + // terminal, and the install stopped on "Site title is required". + $result = runInstallCommand(['--install' => true], interactive: false); + + expect($result['exit'])->toBe(0) + ->and($result['config'])->toBeInstanceOf(InstallationConfig::class) + ->and($result['config']->title)->toBe('Acme Site') + ->and($result['config']->adminUser)->toBe('admin') + ->and($result['config']->adminEmail)->toBe('admin@acme-site.ddev.site') + ->and(strlen($result['config']->adminPassword))->toBe(20) + ->and($result['config']->locale)->toBe('en_US') + ->and($result['config']->isPublic)->toBeFalse(); + }); + + it('shows a generated password once, even in --install mode', function (): void { + $result = runInstallCommand(['--install' => true], interactive: false); + + expect($result['output'])->toContain($result['config']->adminPassword); + }); + + it('keeps every option it is given', function (): void { + $result = runInstallCommand([ + '--install' => true, + '--title' => 'Pollora', + '--admin-user' => 'olivier', + '--admin-email' => 'dev@example.org', + '--admin-password' => 'secret123', + '--locale' => 'fr_FR', + '--public' => 'true', + ], interactive: false); + + expect($result['config']->title)->toBe('Pollora') + ->and($result['config']->adminUser)->toBe('olivier') + ->and($result['config']->adminEmail)->toBe('dev@example.org') + ->and($result['config']->adminPassword)->toBe('secret123') + ->and($result['config']->locale)->toBe('fr_FR') + ->and($result['config']->isPublic)->toBeTrue() + ->and($result['output'])->not->toContain('secret123'); + }); +}); From 93e34f165bd1524f6d08660f40ab82e5bdff3942 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 16:01:17 +0200 Subject: [PATCH 4/9] fix: name the site after the APP_URL host, not /var/www/html --- CHANGELOG.md | 2 +- .../Installation/DTO/InstallationConfig.php | 4 ++-- .../Commands/LaunchPadInstallCommand.php | 20 ++++++++++++++++++- .../Commands/LaunchPadInstallCommandTest.php | 3 ++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f54ced96..0d690454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0...develop) ### Fixed -- `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 directory as title, `admin` at `admin@`, a generated password shown once (also with `--install`), `en_US`, not indexed — and keeps every option it is given +- `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 ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 diff --git a/src/Services/WordPress/Installation/DTO/InstallationConfig.php b/src/Services/WordPress/Installation/DTO/InstallationConfig.php index e0a39f95..5a2c3270 100755 --- a/src/Services/WordPress/Installation/DTO/InstallationConfig.php +++ b/src/Services/WordPress/Installation/DTO/InstallationConfig.php @@ -96,11 +96,11 @@ public static function fromPrompts( * Without a terminal — `--no-interaction`, CI, or `pollora new` driving the * command — prompts cannot be answered, and a required one used to abort the * install on "Site title is required". Every option given is kept; the rest - * gets a value that makes a working local site: the project directory as + * gets a value that makes a working local site: the project's name as * title, `admin` on the site's own domain, a generated password, `en_US`, * not indexed. * - * @param string $projectName Directory name of the project, for the title + * @param string $projectName The project's name, for the title * @param string|null $siteUrl The application URL, for the admin email domain */ public static function withDefaults( diff --git a/src/WordPress/Commands/LaunchPadInstallCommand.php b/src/WordPress/Commands/LaunchPadInstallCommand.php index 3c7d3c6b..609d1443 100644 --- a/src/WordPress/Commands/LaunchPadInstallCommand.php +++ b/src/WordPress/Commands/LaunchPadInstallCommand.php @@ -96,7 +96,7 @@ private function installWordPress(bool $silent = false): void $config = $this->input->isInteractive() ? InstallationConfig::fromPrompts(...$options) - : InstallationConfig::withDefaults(basename(base_path()), config('app.url'), ...$options); + : InstallationConfig::withDefaults($this->projectName(), config('app.url'), ...$options); $this->installationService->install($config); @@ -112,6 +112,24 @@ private function installWordPress(bool $silent = false): void $this->displaySuccessMessage(); } + /** + * The project's name, for a site title nobody was asked for. + * + * The first label of the application's host (`acme.ddev.site` → `acme`): + * under DDEV or Docker the project always lives in `/var/www/html`, whose + * directory name says nothing. The directory name only without a real host. + */ + private function projectName(): string + { + $host = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && str_contains($host, '.')) { + return explode('.', $host)[0]; + } + + return basename(base_path()); + } + private function installTheme(): void { $arguments = []; diff --git a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php index 772d84e5..0c79a7da 100644 --- a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php +++ b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php @@ -82,7 +82,8 @@ public function isLocal(): bool public function basePath(string $path = ''): string { - return '/srv/acme-site'.($path !== '' ? '/'.$path : ''); + // DDEV and Docker keep every project in /var/www/html + return '/var/www/html'.($path !== '' ? '/'.$path : ''); } }; $container->instance('config', new Repository(['app' => ['url' => 'https://acme-site.ddev.site']])); From f3ce0d25f9775827bbe091b6d2a03eb497e90585 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 1 Oct 2026 17:56:44 +0200 Subject: [PATCH 5/9] docs: README and Packagist description for discovery Say what Pollora is in the first line, link the site's why, compare and guide pages, show the attribute style, recommend the CLI, and fix the requirements (PHP 8.4+ for a new project, Laravel 13.34, WordPress 7.1+). --- README.md | 45 +++++++++++++++++++++++++++++++++++++-------- composer.json | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1f1d8c8c..a7bd7f9f 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,29 @@ License

-## About Pollora Framework +## About Pollora -Pollora is a framework that bridges **Laravel** and **WordPress**, combining Laravel's architecture patterns with WordPress's content management capabilities. It allows developers to use Laravel's service providers, dependency injection, Blade templates, and Eloquent ORM while maintaining full WordPress functionality. +**Pollora is the Laravel framework for WordPress.** WordPress runs inside a Laravel application: the front end uses Laravel routing, controllers, Blade and Eloquent, while the WordPress admin, database, editors' workflow and plugins keep working as usual. Hooks, post types, taxonomies and REST routes are declared with PHP 8 attributes and registered by auto-discovery. + +[Website](https://pollora.dev) · [Documentation](https://pollora.dev/getting-started/installation/) · [Why Pollora](https://pollora.dev/why/) · [Pollora vs Acorn, Sage, Radicle and Corcel](https://pollora.dev/compare/) · [1-minute tour](https://www.youtube.com/watch?v=Wk1VzPapqM8) + +```php +use Pollora\Attributes\Filter; +use Pollora\Attributes\PostType; +use Pollora\Attributes\PostType\HasArchive; +use Pollora\Attributes\PostType\Supports; + +#[PostType] +#[HasArchive] +#[Supports(['title', 'editor', 'thumbnail'])] +class Book {} + +class Seo +{ + #[Filter('document_title_parts')] + public function title(array $parts): array { /* ... */ return $parts; } +} +``` ### Key Features @@ -32,19 +52,28 @@ Full documentation is available at **[pollora.dev](https://pollora.dev)**. ## Installation -Pollora is installed via the [skeleton project](https://github.com/Pollora/pollora) — see the [installation guide](https://pollora.dev/getting-started/installation/): +Create a project with the [Pollora CLI](https://github.com/Pollora/cli), or with Composer through the [skeleton](https://github.com/Pollora/pollora). See the [installation guide](https://pollora.dev/getting-started/installation/): ```bash +composer global require pollora/cli +pollora new my-project --ddev + +# or composer create-project pollora/pollora my-project ``` -See the [skeleton README](https://github.com/Pollora/pollora) for detailed setup instructions. - ## Requirements -- PHP ^8.3 -- Laravel 13.x -- WordPress 6.9+ +- PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3) +- Laravel 13.34 (Pollora's version numbers follow the Laravel release it is built on) +- WordPress 7.1+ + +## Learn more + +- [WordPress hooks with PHP 8 attributes](https://pollora.dev/guides/wordpress-hooks-php-attributes/) +- [Custom post types and taxonomies with PHP attributes](https://pollora.dev/guides/custom-post-types-php-attributes/) +- [How Pollora runs WordPress inside Laravel](https://pollora.dev/guides/how-pollora-runs-wordpress-inside-laravel/) +- [AI coding agents for WordPress projects (Nectar)](https://pollora.dev/guides/ai-coding-agents-wordpress/) ## Testing diff --git a/composer.json b/composer.json index 3983876d..fd89485f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "pollora/framework", - "description": "Laravel & WordPress blend for coding as sweet as honey. Join the hive!", + "description": "The Laravel framework for WordPress: Laravel routing, Blade and Eloquent, with PHP 8 attributes for hooks, post types, taxonomies and REST routes.", "license": "MIT", "homepage": "https://pollora.dev", "support": { From e0fb454df97fb9de37dec9b5d53a15587cecb8a5 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 1 Oct 2026 18:02:15 +0200 Subject: [PATCH 6/9] docs: compare link wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7bd7f9f..f03729bf 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ **Pollora is the Laravel framework for WordPress.** WordPress runs inside a Laravel application: the front end uses Laravel routing, controllers, Blade and Eloquent, while the WordPress admin, database, editors' workflow and plugins keep working as usual. Hooks, post types, taxonomies and REST routes are declared with PHP 8 attributes and registered by auto-discovery. -[Website](https://pollora.dev) · [Documentation](https://pollora.dev/getting-started/installation/) · [Why Pollora](https://pollora.dev/why/) · [Pollora vs Acorn, Sage, Radicle and Corcel](https://pollora.dev/compare/) · [1-minute tour](https://www.youtube.com/watch?v=Wk1VzPapqM8) +[Website](https://pollora.dev) · [Documentation](https://pollora.dev/getting-started/installation/) · [Why Pollora](https://pollora.dev/why/) · [How Pollora compares with Acorn, Sage, Radicle and Corcel](https://pollora.dev/compare/) · [1-minute tour](https://www.youtube.com/watch?v=Wk1VzPapqM8) ```php use Pollora\Attributes\Filter; From 5f632732d205bdb08166e4141c9bc3b93e44a02a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 1 Oct 2026 18:02:32 +0200 Subject: [PATCH 7/9] docs: credit Roots and Corcel --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f03729bf..f0ef2d6b 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ If you discover a security vulnerability, please report it via [GitHub Security All notable changes are documented in [CHANGELOG.md](CHANGELOG.md). +## Credits + +Pollora builds on the work of [Roots](https://roots.io), a constant source of inspiration: themes, blocks and plugins are built with `@roots/vite-plugin`, the project follows the Bedrock layout with `roots/bedrock-autoloader`, and Blade ships [Sage Directives](https://github.com/Log1x/sage-directives). The WordPress models are built on [Colt](https://github.com/Pollora/colt), a fork of [Corcel](https://github.com/corcel/corcel). + ## License Pollora is open-sourced software licensed under the [MIT license](LICENSE). From 3f84b93d6b2d8e742b97b8bf54ba43308395286d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 2 Oct 2026 17:09:56 +0200 Subject: [PATCH 8/9] 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 } }); + } +}); From b846478f3021b9131edea9b21e8e572e302c8d0f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 2 Oct 2026 17:20:10 +0200 Subject: [PATCH 9/9] chore: release v13.34.1 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 675afd8b..7d281bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ 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.34.0...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.1...develop) + +## [v13.34.1](https://github.com/Pollora/framework/compare/v13.34.0...v13.34.1) - 2026-10-02 ### Removed - 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)