diff --git a/CHANGELOG.md b/CHANGELOG.md index b11678d7..7d281bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ 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) + +### 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) ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 diff --git a/README.md b/README.md index 1f1d8c8c..f0ef2d6b 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/) · [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; +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 @@ -68,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). 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": { 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. * 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/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/src/Services/WordPress/Installation/DTO/InstallationConfig.php b/src/Services/WordPress/Installation/DTO/InstallationConfig.php index f88a7bfe..5a2c3270 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's name as + * title, `admin` on the site's own domain, a generated password, `en_US`, + * not indexed. + * + * @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( + 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/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/src/WordPress/Commands/LaunchPadInstallCommand.php b/src/WordPress/Commands/LaunchPadInstallCommand.php index 05c6dc59..609d1443 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($this->projectName(), 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(); @@ -102,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/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') 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/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php index 0e9e039e..0c79a7da 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,14 @@ public function isLocal(): bool { return false; } + + public function basePath(string $path = ''): string + { + // 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']])); // handleError() resolves app() to check the environment $previousContainer = Container::getInstance(); Container::setInstance($container); @@ -90,12 +102,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 +176,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'); + }); +}); 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 } }); + } +});