diff --git a/.claude/plans/route-domain-refactoring.md b/.claude/plans/route-domain-refactoring.md deleted file mode 100644 index 65117233..00000000 --- a/.claude/plans/route-domain-refactoring.md +++ /dev/null @@ -1,162 +0,0 @@ -# Étude : Refactoring Route.php — Découplage Domain/Infrastructure - -## Problème - -`src/Route/Domain/Models/Route.php` extends `Illuminate\Routing\Route` directement dans la couche Domain. C'est la dernière violation DDD majeure du framework. - -### Pourquoi c'est structurant - -- Le Domain routing est couplé à l'implémentation Laravel -- 13 fichiers (5 src + 8 tests) référencent directement cette classe -- 4 middlewares font des `instanceof Route` checks -- Le `matches()` override contient la logique métier WordPress (condition functions) -- `ExtendedRouter` remplace le router Laravel pour créer nos instances Route -- Les macros `Route::wp()` et `Route::wpMatch()` manipulent directement les properties WordPress - -### Ce que Route.php ajoute à IlluminateRoute - -**4 properties WordPress :** -- `$isWordPressRoute: bool` -- `$condition: string` (ex: `is_single`, `is_page`) -- `$conditionParameters: array` -- `$conditionResolver: ConditionResolverInterface` - -**8 méthodes WordPress :** -- `setIsWordPressRoute()` / `isWordPressRoute()` -- `setCondition()` / `getCondition()` / `hasCondition()` -- `setConditionParameters()` / `getConditionParameters()` -- `setConditionResolver()` - -**1 override :** -- `matches()` — diverge entre matching WordPress (condition function) et matching Laravel (URI pattern) - -**Méthodes héritées utilisées par les consumers :** -- `getAction()`, `setParameter()`, `parameter()`, `hasParameter()`, `uri()`, `getCompiled()`, `middleware()` - ---- - -## Approche recommandée : Composition + Interface - -### Étape 1 — Créer `WordPressRouteInterface` (Domain) - -```php -// src/Route/Domain/Contracts/WordPressRouteInterface.php -interface WordPressRouteInterface -{ - public function isWordPressRoute(): bool; - public function setIsWordPressRoute(bool $isWordPressRoute): static; - public function getCondition(): string; - public function setCondition(string $condition): static; - public function hasCondition(): bool; - public function getConditionParameters(): array; - public function setConditionParameters(array $parameters): static; - public function setConditionResolver(ConditionResolverInterface $resolver): static; -} -``` - -### Étape 2 — Déplacer Route vers Infrastructure - -``` -src/Route/Domain/Models/Route.php - → src/Route/Infrastructure/Models/Route.php -``` - -Le namespace change : `Pollora\Route\Infrastructure\Models\Route`. - -La classe reste identique (extends IlluminateRoute + implements WordPressRouteInterface). Le couplage Laravel est désormais dans la couche Infrastructure où il a sa place. - -### Étape 3 — Mettre à jour les imports (13 fichiers) - -| Fichier | Action | -|---------|--------| -| `ExtendedRouter.php` | Update import | -| `WordPressRoutingService.php` | Type-hint → `WordPressRouteInterface` | -| `WordPressBindings.php` | `instanceof WordPressRouteInterface` | -| `WordPressBodyClass.php` | `instanceof WordPressRouteInterface` — **attention** : utilise aussi des méthodes héritées Laravel (`getCompiled`, `parameter`, `hasParameter`) | -| `WordPressHeaders.php` | `method_exists` → `instanceof WordPressRouteInterface` | -| `BindWordPressParametersUseCase.php` | Type-hint → `WordPressRouteInterface` — **attention** : utilise `getAction()`, `setParameter()`, `uri()` | -| `RouteServiceProvider.php` | Macros utilisent `$route->setIsWordPressRoute()` etc. | -| 8 fichiers tests | Update imports | - -### Étape 4 — Problème `BindWordPressParametersUseCase` (Application layer) - -Ce use case est dans la couche Application et utilise : -- `$route->getAction()` — méthode de IlluminateRoute, pas de WordPressRouteInterface -- `$route->setParameter()` — idem -- `$route->uri()` — idem - -**Options :** -1. **Étendre l'interface** avec ces méthodes (mais ça tire les concepts Laravel dans le Domain) -2. **Créer une interface `RoutableInterface`** dans Domain avec `getAction()`, `setParameter()`, `uri()`, `parameter()`, `hasParameter()` — ces concepts sont assez génériques pour exister au niveau Domain -3. **Garder le type Laravel `Illuminate\Routing\Route`** dans le use case (le use case est Application, pas Domain — acceptable) - -**Recommandation :** Option 3 pour l'instant. Le use case accepte `Illuminate\Routing\Route` directement — c'est la couche Application, elle peut dépendre de l'Infrastructure. Seul le Domain doit rester pur. - -### Étape 5 — Problème `WordPressBodyClass` middleware - -Utilise des méthodes Laravel (`getCompiled()`, `parameter()`, `hasParameter()`) : -- C'est un middleware Infrastructure — acceptable d'utiliser le type concret `Route` -- Ou bien : type-hint `Illuminate\Routing\Route` pour les méthodes Laravel + `instanceof WordPressRouteInterface` pour les méthodes WordPress - -### Étape 6 — Backward compatibility - -Ajouter un class_alias dans `RouteServiceProvider` : -```php -if (! class_exists('Pollora\\Route\\Domain\\Models\\Route')) { - class_alias(Route::class, 'Pollora\\Route\\Domain\\Models\\Route'); -} -``` - ---- - -## Impact estimé - -| Métrique | Valeur | -|----------|--------| -| Fichiers source modifiés | 7 | -| Fichiers tests modifiés | 8 | -| Nouveau fichier | 1 (WordPressRouteInterface) | -| Risque | **Moyen** — le routing est critique, mais le changement est structurel (move + interface), pas fonctionnel | -| Effort | ~1-2h | -| Bénéfice | Domain 100% pur des imports `Illuminate\Routing\*` | - -## Résultat après refactoring - -### Violations Domain `Illuminate\*` restantes - -| Fichier | Import | Status | -|---------|--------|--------| -| `ModuleCollection.php` | extends `Collection` | **Accept** — Collection est quasi-standard | -| `PluginCollection.php` | extends `Collection` | **Accept** — idem | -| `DiscoveryEngineInterface.php` | retourne `Collection` | **Accept** — interface publique | -| ~~`Route.php`~~ | ~~extends `IlluminateRoute`~~ | ✅ **Résolu** — déplacé en Infrastructure | - -**Domain violations : 4 → 3** (les 3 restantes sont toutes `Collection`, un trade-off accepté). - ---- - -## Pré-requis - -- Branche `release/v13.4.0` ou nouvelle branche feature -- 999 tests doivent continuer à passer -- PHPStan 0 erreurs -- Pint clean -- 67 tests intégration skeleton verts - -## Vérification - -```bash -ddev exec --dir /var/www/html/vendor/pollora/framework composer test -ddev composer test:integration -``` - -## Ordre d'implémentation suggéré - -1. Créer `WordPressRouteInterface` dans Domain/Contracts -2. Faire implémenter l'interface par `Route.php` -3. `git mv` Route.php vers Infrastructure/Models -4. Mettre à jour les 5 fichiers source (imports, type-hints) -5. Ajouter le class_alias backward compat -6. Mettre à jour les 8 fichiers tests -7. Mettre à jour le baseline PHPStan (les macros sont dans le baseline) -8. Vérifier les 999 tests + 67 intégration \ No newline at end of file diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 5d36a5a0..00000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,19 +0,0 @@ -engines: - phpcodesniffer: - enabled: true - config: - file_extensions: "php" - standard: "PSR1,PSR2" - ignore_warnings: true - encoding: utf-8 - phpmd: - enabled: true - config: - file_extensions: "php" - rulesets: "codesize,controversial,design,phpmd.xml" - checks: - CyclomaticComplexity: - enabled: false -ratings: - paths: - - "**.php" diff --git a/.cursor/rules/pollora-architecture.mdc b/.cursor/rules/pollora-architecture.mdc deleted file mode 100644 index 66d4fffb..00000000 --- a/.cursor/rules/pollora-architecture.mdc +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: Pollora Framework - Domain-Driven Design Architecture -alwaysApply: true ---- - -# Pollora DDD Architecture Standards - -## Strict Module Structure -Always follow the DDD module structure for new components: - -``` -src/[Module]/ -├── Application/Services/ # Application layer - use cases and orchestration -├── Domain/ -│ ├── Contracts/ # Interfaces and contracts -│ ├── Models/ # Domain entities and value objects -│ ├── Services/ # Domain services and business logic -│ └── Exceptions/ # Domain-specific exceptions -├── Infrastructure/ -│ ├── Providers/ # Service providers for DI container -│ ├── Repositories/ # Data persistence implementations -│ ├── Services/ # Infrastructure services (external concerns) -│ └── Adapters/ # Adapters for external systems -└── UI/ - ├── Console/ # Artisan commands - └── Http/ # HTTP controllers and middleware -``` - -## Layer Responsibilities -- **Domain**: Business logic, entities, contracts - NO external dependencies -- **Application**: Use cases, orchestration - depends only on Domain -- **Infrastructure**: Technical implementations, databases, external APIs -- **UI**: Controllers, commands, presentation logic - -## Namespace Conventions -- Framework classes: `Pollora\[Module]\[Layer]\` -- Theme classes: `Theme\{ThemeName}\` (dynamically autoloaded) -- Plugin classes: `Plugin\{PluginName}\` (future support) - -## Service Provider Registration -- Each module MUST have a dedicated service provider -- Register in dependency order within `PolloraServiceProvider` -- WordPress services registered AFTER Laravel core services \ No newline at end of file diff --git a/.cursor/rules/pollora-attributes.mdc b/.cursor/rules/pollora-attributes.mdc deleted file mode 100644 index 74ba37c4..00000000 --- a/.cursor/rules/pollora-attributes.mdc +++ /dev/null @@ -1,68 +0,0 @@ ---- -description: Pollora Framework - PHP 8 Attributes for Declarative Configuration -globs: ["**/*.php"] -alwaysApply: false ---- - -# Attribute-Driven Configuration - -## Available Attributes -Use these PHP 8 attributes for declarative configuration: - -### WordPress Integration -```php -#[PostType( - name: 'custom_post', - args: ['public' => true, 'supports' => ['title', 'editor']] -)] -class CustomPost {} - -#[Taxonomy( - name: 'custom_taxonomy', - postTypes: ['post', 'custom_post'], - args: ['hierarchical' => true] -)] -class CustomTaxonomy {} - -#[Action( - hook: 'init', - priority: 10, - acceptedArgs: 1 -)] -public function initMethod() {} - -#[Filter( - hook: 'the_content', - priority: 10, - acceptedArgs: 1 -)] -public function filterContent(string $content): string {} -``` - -### Scheduling & REST API -```php -#[Schedule( - cron: '0 0 * * *', - method: 'dailyTask' -)] -class ScheduledTask {} - -#[WpRestRoute( - endpoint: '/custom/v1/data', - methods: ['GET', 'POST'] -)] -public function handleRestRequest() {} -``` - -## Best Practices -- Use attributes instead of manual hook registration -- Place attributes directly above the class or method they configure -- Keep attribute parameters declarative and simple -- The Discovery system will automatically find and register these attributes -- Prefer convention over configuration where possible - -## Discovery System -- Classes with attributes are automatically discovered by `Discoverer` module -- No manual registration needed for attributed classes -- Follows convention-over-configuration approach -- Attributes are processed during service provider boot phase \ No newline at end of file diff --git a/.cursor/rules/pollora-autoloading.mdc b/.cursor/rules/pollora-autoloading.mdc deleted file mode 100644 index d1dd1eaa..00000000 --- a/.cursor/rules/pollora-autoloading.mdc +++ /dev/null @@ -1,53 +0,0 @@ ---- -description: Pollora Framework - Dynamic Autoloading System -globs: ["themes/**/app/**/*.php", "themes/**/src/**/*.php", "plugins/**/app/**/*.php"] -alwaysApply: false ---- - -# Dynamic Autoloading System - -## Namespace Conventions -**Fixed namespace patterns** for automatic PSR-4 autoloading: - -### Themes -- **Namespace**: `Theme\{ThemeName}\` -- **Directory**: `themes/{theme-name}/app/` (preferred) or `themes/{theme-name}/src/` (fallback) - -Example: -```php -// File: themes/solidarmonde/app/Providers/ThemeServiceProvider.php -namespace Theme\Solidarmonde\Providers; - -class ThemeServiceProvider extends ServiceProvider {} -``` - -### Plugins (Future Support) -- **Namespace**: `Plugin\{PluginName}\` -- **Directory**: `plugins/{plugin-name}/app/` or `plugins/{plugin-name}/src/` - -## Directory Structure -``` -themes/solidarmonde/ -├── app/ # PSR-4 autoloaded (preferred) -│ ├── Providers/ -│ │ └── ThemeServiceProvider.php -│ ├── Models/ -│ ├── Services/ -│ └── Controllers/ -├── src/ # PSR-4 autoloaded (fallback) -└── views/ # NOT autoloaded -``` - -## Autoloading Flow -1. `ModuleBootstrap` discovers service providers -2. `ThemeServiceProviderScout` scans for providers -3. `LaravelThemeModule::register()` calls `registerAutoloading()` -4. `ThemeAutoloader` maps namespaces to directories -5. Classes become accessible with proper namespace - -## Best Practices -- Use `app/` directory for new themes (preferred over `src/`) -- Follow PSR-4 standards for class naming -- Service providers are automatically discovered -- No manual composer.json modifications needed -- Namespace must match theme/plugin directory name (PascalCase) diff --git a/.cursor/rules/pollora-dependency-injection.mdc b/.cursor/rules/pollora-dependency-injection.mdc deleted file mode 100644 index 06f0275e..00000000 --- a/.cursor/rules/pollora-dependency-injection.mdc +++ /dev/null @@ -1,131 +0,0 @@ ---- -description: Pollora Framework - Dependency Injection & Service Resolution Best Practices -globs: ["**/*.php"] -alwaysApply: false ---- - -# Dependency Injection & Service Resolution - -## NEVER Use Laravel Facades -❌ **Avoid Laravel facades** - Always use the original classes: - -```php -// ❌ DON'T -use Illuminate\Support\Facades\Cache; -Cache::get('key'); - -// ✅ DO -use Illuminate\Cache\Repository; -class MyService { - public function __construct(private Repository $cache) {} -} -``` - -## Service Resolution Priority - -### 1. Dependency Injection (PREFERRED) -✅ **Always prefer constructor injection or method injection when possible:** - -```php -class MyServiceProvider extends ServiceProvider -{ - public function __construct( - private Action $action, - private Filter $filter - ) {} - - public function boot(Action $action, Filter $filter): void - { - // Method injection in boot() is supported - $action->add('init', $this->initMethod(...)); - } -} -``` - -### 2. Container Resolution -When dependency injection isn't possible, use container resolution: - -```php -// ✅ PREFERRED: app()->get() (better performance) -$action = $this->app->get(Action::class); -$filter = $this->app->get(Filter::class); - -// ✅ ALTERNATIVE: app()->make() (when get() isn't available) -$service = $this->app->make(SomeService::class); -``` - -## WordPress Hooks Integration - -### Available Hook Services -Use Pollora's dedicated hook services instead of direct WordPress functions: - -```php -use Pollora\Hook\Infrastructure\Services\Action; -use Pollora\Hook\Infrastructure\Services\Filter; -``` - -### Service Resolution for Hooks -```php -class MyServiceProvider extends ServiceProvider -{ - private Action $action; - private Filter $filter; - - public function boot(): void - { - // Resolve hook services - $this->action = $this->app->get(Action::class); - $this->filter = $this->app->get(Filter::class); - - $this->registerHooks(); - } -} -``` - -### Hook Registration Examples -```php -private function registerHooks(): void -{ - // Actions - $this->action->add('after_setup_theme', $this->registerMenus(...), 1); - $this->action->add('init', $this->initMethod(...), 10); - - // Filters - $this->filter->add('nav_menu_link_attributes', $this->handleLinkAttributes(...), 10, 4); - $this->filter->add('nav_menu_item_attributes', $this->handleItemAttributes(...), 10, 4); - $this->filter->add('nav_menu_submenu_attributes', $this->handleSubmenuAttributes(...), 10, 3); - $this->filter->add('the_content', $this->modifyContent(...), 20, 1); -} -``` - -### Available Hook Methods -Both `Action` and `Filter` services provide: - -```php -// Add hooks -$this->action->add('hook_name', $callback, $priority, $accepted_args); -$this->filter->add('hook_name', $callback, $priority, $accepted_args); - -// Remove hooks -$this->action->remove('hook_name', $callback, $priority); -$this->filter->remove('hook_name', $callback, $priority); - -// Execute hooks -$this->action->do('hook_name', ...$args); -$result = $this->filter->apply('hook_name', $value, ...$args); -``` - -## Best Practices Summary - -1. **Never use Laravel facades** - always use original classes -2. **Prioritize dependency injection** in constructors and methods -3. **Use `app()->get()`** for better performance when DI isn't possible -4. **Use `app()->make()`** only when `get()` isn't available -5. **Use Pollora hook services** instead of direct `add_action`/`add_filter` -6. **Resolve hook services once** and reuse the instances -7. **Register hooks in service provider `boot()` method** - -## Performance Notes -- `app()->get()` is faster than `app()->make()` for singleton services -- Dependency injection is the most performant option -- Cache service instances when possible to avoid repeated resolution diff --git a/.cursor/rules/pollora-service-providers.mdc b/.cursor/rules/pollora-service-providers.mdc deleted file mode 100644 index 2747e15c..00000000 --- a/.cursor/rules/pollora-service-providers.mdc +++ /dev/null @@ -1,54 +0,0 @@ ---- -description: Pollora Framework - Service Provider Patterns -globs: ["**/*ServiceProvider.php", "**/Providers/**/*.php"] -alwaysApply: false ---- - -# Service Provider Standards - -## Service Provider Structure -Always follow this pattern for service providers: - -```php -app->bind(ContractInterface::class, ConcreteImplementation::class)` -- Use singletons for stateless services: `$this->app->singleton()` -- Tag related services: `$this->app->tag([...], 'tag-name')` - -## WordPress Integration -- WordPress hooks registration happens in service provider `boot()` method -- Use Laravel's service container within WordPress hooks -- Ensure WordPress is loaded before accessing WordPress functions diff --git a/.cursor/rules/pollora-testing-quality.mdc b/.cursor/rules/pollora-testing-quality.mdc deleted file mode 100644 index 0009b7c4..00000000 --- a/.cursor/rules/pollora-testing-quality.mdc +++ /dev/null @@ -1,52 +0,0 @@ ---- -description: Pollora Framework - Testing & Code Quality Standards -globs: ["**/tests/**/*.php", "**/*Test.php"] -alwaysApply: false ---- - -# Testing & Quality Standards - -## Required Quality Standards -- **Test Coverage**: 100% test coverage required (`--min=100`) -- **Static Analysis**: PHPStan level 5 with WordPress and Laravel extensions -- **Code Style**: Laravel Pint for PHP formatting -- **Refactoring**: Rector with Laravel-specific rules - -## Test Organization -Tests organized by architectural layer: -``` -tests/ -├── Unit/ # Domain logic and services -├── Feature/ # Integration testing across layers -└── Scouts/ # Discovery system component testing -``` - -## Development Commands -```bash -# Run all quality checks -ddev exec --dir /var/www/html/vendor/pollora/framework composer test - -# Individual commands -ddev exec --dir /var/www/html/vendor/pollora/framework composer test:unit # PHPUnit with 100% coverage -ddev exec --dir /var/www/html/vendor/pollora/framework composer test:types # PHPStan static analysis -ddev exec --dir /var/www/html/vendor/pollora/framework composer test:lint # Code formatting check -ddev exec --dir /var/www/html/vendor/pollora/framework composer test:refacto # Refactoring rules check (dry-run) - -# Fix issues -ddev exec --dir /var/www/html/vendor/pollora/framework composer lint # Fix code formatting -ddev exec --dir /var/www/html/vendor/pollora/framework composer refacto # Apply refactoring rules -``` - -## Test Structure -- Unit tests for Domain layer (pure business logic) -- Feature tests for Application layer (use cases) -- Integration tests for Infrastructure layer -- Use Laravel's TestCase with WordPress integration via testbench.yaml - -## Best Practices -- Test behavior, not implementation -- Follow AAA pattern (Arrange, Act, Assert) -- Use meaningful test method names -- Mock external dependencies in unit tests -- Use factories for test data creation -- Test edge cases and error conditions diff --git a/.cursor/rules/pollora-wordpress-integration.mdc b/.cursor/rules/pollora-wordpress-integration.mdc deleted file mode 100644 index e4a2c5cf..00000000 --- a/.cursor/rules/pollora-wordpress-integration.mdc +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Pollora Framework - WordPress-Laravel Integration Bridge -globs: ["**/WordPress/**/*.php", "**/Bootstrap/**/*.php", "**/Template/**/*.php"] -alwaysApply: false ---- - -# WordPress-Laravel Integration - -## Bootstrap Process -The `WordPress\Bootstrap` class is the critical bridge: -- Manages WordPress constants and configuration -- Handles database configuration mapping -- Controls WordPress initialization flow -- Manages URL schemes and routing - -## WordPress Integration Points - -### Database Bridge -Laravel database config automatically maps to WordPress constants: -```php -// Laravel config maps to WordPress -DB_HOST → WordPress DB_HOST -DB_NAME → WordPress DB_NAME -DB_USER → WordPress DB_USER -DB_PASSWORD → WordPress DB_PASSWORD -``` - -### Authentication Integration -- Custom Laravel guards for WordPress authentication -- WordPress-compatible password hashing -- User session management bridge - -### Template System -- Blade templates with WordPress template hierarchy -- `TemplateHierarchy` module for template resolution -- Support for both Blade and PHP templates - -### Asset Management -- Vite integration with HMR support -- Automatic WordPress script/style enqueuing -- Asset containers for theme-specific resources - -## WordPress Compatibility -- Patches applied to WordPress core for Laravel compatibility -- Custom function renaming to avoid conflicts (`__` function, `wp_mail`) -- Conditional WordPress loading (console vs web environment) - -## Best Practices -- Always check if WordPress is loaded before using WordPress functions -- Use Laravel's service container within WordPress hooks -- Prefer Laravel patterns (facades, dependency injection) over global WordPress functions -- WordPress hooks should be registered in service provider `boot()` method -- Use `app()` helper to resolve services within WordPress contexts -- Environment-aware constant definition for WordPress configuration - -## Events System -Comprehensive WordPress event dispatching: -- Core WordPress events (posts, users, comments) -- Plugin-specific events (WooCommerce, Gravity Forms, Yoast SEO) -- Custom application events through Laravel's event system diff --git a/.gitattributes b/.gitattributes index 5c247f99..67c2e50d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,10 @@ # Left out of the archive Composer installs: what only developing the # framework needs. The browser tests stay reachable by cloning the repository, # which is how theme CI fetches them. -/.claude export-ignore -/.codeclimate.yml export-ignore -/.cursor export-ignore /.github export-ignore /.gitattributes export-ignore /.gitmodules export-ignore /.husky export-ignore -/.php-cs-fixer.cache export-ignore -/.styleci.yml export-ignore -/.windsurf export-ignore /CLAUDE.md export-ignore /commitlint.config.js export-ignore /documentation export-ignore diff --git a/.gitignore b/.gitignore index 616608d4..826436d0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ claude framework.code-workspace examples/ .claude/ + +# Editor and AI-assistant settings stay local; project guidelines live in CLAUDE.md and Nectar +/.cursor/ +/.windsurf/ diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index 0fcca98b..00000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.0.29","version":"3.8.0:v3.8.0#cbad1115aac4b5c3c5540e7210d3c9fba2f81fa3","indent":" ","lineEnding":"\n","rules":{"array_syntax":{"syntax":"short"}},"hashes":{"src\/Mail\/Mailer.php":1660177352,"src\/Models\/Comment.php":3873229117,"src\/Models\/Post.php":1798921485,"src\/Support\/WordPress.php":3587404917,"src\/Proxy\/WordPressCache.php":3603413932,"src\/Proxy\/WordPressDatabase.php":1855621534,"src\/helpers.php":440031269,"src\/Route\/Matching\/ConditionValidator.php":2986620342,"src\/Route\/Route.php":2274020461,"src\/Providers\/WordPressServiceProvider.php":1520812535,"src\/Providers\/WordPressTemplatingServiceProvider.php":1700811233,"src\/Hook\/ActionBuilder.php":1440079012,"src\/Hook\/FilterBuilder.php":2681353283,"src\/Hook\/Hook.php":1499477558}} \ No newline at end of file diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 1c53b1a6..00000000 --- a/.styleci.yml +++ /dev/null @@ -1,35 +0,0 @@ -preset: laravel - -risky: true - -enabled: - - unalign_double_arrow - - combine_consecutive_unsets - - concat_with_spaces - - dir_constant - - ereg_to_preg - - linebreak_after_opening_tag - - modernize_types_casting - - no_blank_lines_before_namespace - - no_empty_comment - - no_php4_constructor - - no_short_echo_tag - - no_useless_else - - ordered_class_elements - - ordered_imports - - php_unit_construct - - php_unit_dedicate_assert - - php_unit_strict - - phpdoc_order - - phpdoc_property - - phpdoc_separation - - random_api_migration - - semicolon_after_instruction - - strict_comparison - - strict_param - -disabled: - - concat_without_spaces - - single_blank_line_before_namespace - - not_operator_with_successor_space - - length_ordered_imports diff --git a/.windsurf/rules/guidelines.md b/.windsurf/rules/guidelines.md deleted file mode 100644 index a0d08c90..00000000 --- a/.windsurf/rules/guidelines.md +++ /dev/null @@ -1,260 +0,0 @@ ---- -trigger: always_on -description: -globs: ---- - -# Pollora & PHP Coding Standards for AI Code Assistants - -This document outlines the Pollora and PHP development standards, structured for optimal compatibility with AI tools such as GitHub Copilot, Cursor, and Claude Code. These conventions are adapted from Spatie’s Laravel & PHP guidelines. - -## Laravel Foundation - -**Always follow official Laravel conventions first.** Deviation is acceptable only when justified by a valid technical reason. - -## PHP Standards - -* Adhere to PSR-1, PSR-2, and PSR-12 -* Use `camelCase` for all internal string keys and parameters -* Prefer `?Type` over `Type|null` -* Explicitly return `void` when nothing is returned - -## Class Design - -* Use typed properties, not docblocks -* Use constructor property promotion when applicable -* Declare one trait per line - -## Typing & Docblocks - -* Favor typed properties over annotations -* Define return types, including `void` -* Use generics for iterable types: - - ```php - /** @return Collection */ - public function users(): Collection - ``` - -### Docblock Rules - -* Avoid docblocks for fully typed methods (unless needed for clarity) -* Always import class names in docblocks: - - ```php - use Pollora\Url\Url; - /** @return Url */ - ``` -* Use one-line annotations where applicable: `/** @var string */` -* For multi-type hints, list the most common type first: - - ```php - /** @var Collection|Custom\Collection */ - ``` -* If one parameter has a docblock, all should -* Always define key/value for iterables: - - ```php - /** - * @param array $items - * @param int $id - */ - function process(array $items, int $id): void {} - ``` -* Use array shape syntax for structured arrays: - - ```php - /** @return array{ - first: SomeClass, - second: SomeClass - } */ - ``` - -## Control Flow - -* Handle edge cases first (happy path last) -* Prefer early return over `else` -* Avoid compound conditions; separate `if` statements improve clarity -* Always use curly braces `{}` even for single-line blocks -* Format ternary operators for readability - -```php -if (! $user) { - return null; -} - -if (! $user->isActive()) { - return null; -} - -// Handle active user -``` - -```php -$name = $isFoo ? 'foo' : 'bar'; - -$result = $item instanceof Model - ? $item->name - : 'default'; -``` - -## Pollora-Specific Conventions - -### Routes - -* URL paths: kebab-case (`/open-source`) -* Route names: camelCase (`->name('openSource')`) -* Parameters: camelCase (`{userId}`) -* Controller references: tuple syntax `[Controller::class, 'method']` - -### Controllers - -* Use plural resource names (`PostsController`) -* Stick to standard CRUD methods -* Isolate non-CRUD logic into dedicated controllers - -### Configuration - -* File names: kebab-case (`pdf-generator.php`) -* Keys: snake\_case (`chrome_path`) -* Extend `config/services.php` instead of creating new config files -* Use `config()` for access, not `env()` (outside config files) - -### Artisan Commands - -* Use kebab-case (`delete-old-records`) -* Always give user feedback (`$this->comment('Done')`) -* Output before processing for easier debugging -* Show progress and final summary - -```php -$items->each(function (Item $item) { - $this->info("Processing ID {$item->id}..."); - $this->processItem($item); -}); - -$this->comment("Total: {$items->count()} items processed."); -``` - -## String Handling - -* Prefer interpolation over concatenation - -## Enums - -* Enum values use PascalCase - -## Comments - -* Write expressive code to reduce comment need -* When needed, format clearly: - -```php -// Brief description - -/* - * Multiline block - */ -``` - -* Replace comments with descriptive method names when possible - -## Whitespace - -* Separate logical blocks with blank lines -* No extra spacing between brackets -* Group related single-line operations without separation - -## Validation - -* Use array syntax for rules (makes custom rules easier to inject): - - ```php - return [ - 'email' => ['required', 'email'], - ]; - ``` -* Custom rules use snake\_case: - - ```php - Validator::extend('organisation_type', fn($attribute, $value) => OrganisationType::isValid($value)); - ``` - -## Blade Templates - -* Use 4-space indentation -* No space after control structures: - - ```blade - @if($active) - ... - @endif - ``` - -## Authorization - -* Use camelCase in policies: `Gate::define('editPost', ...)` -* Favor CRUD naming; prefer `view` over `show` - -## Translations - -* Use `__()` instead of `@lang` - -## API Routing - -* Use plural resources: `/errors` -* Use kebab-case: `/error-occurrences` -* Avoid deep nesting: - - ``` - /error-occurrences/1 - /errors/1/occurrences - ``` - -## Testing - -* Where practical, co-locate related test classes -* Use descriptive method names -* Follow arrange–act–assert pattern - ---- - -## Quick Reference - -### Naming - -| Element | Convention | Example | -| ------------------- | ----------- | -------------------------- | -| Classes | PascalCase | `UserController` | -| Methods / Variables | camelCase | `getUserName`, `$userData` | -| Routes | kebab-case | `/open-source` | -| Config Files | kebab-case | `pdf-generator.php` | -| Config Keys | snake\_case | `chrome_path` | -| Artisan Commands | kebab-case | `delete-old-records` | - -### Structure - -* Controllers: `PostsController` -* Views: `openSource.blade.php` -* Jobs: `SendEmailNotification` -* Events: `UserRegistered` -* Listeners: `SendWelcomeMailListener` -* Commands: `PublishScheduledPostsCommand` -* Mailables: `AccountActivatedMail` -* Resources: `UsersResource` -* Enums: `BookingStatus` - -### Migrations - -* Only use `up()` methods, no need for `down()` unless rollback is required - ---- - -## Code Quality Checklist - -* Typed properties > docblocks -* Early return > nested logic -* Use constructor property promotion -* Avoid `else` where possible -* Use string interpolation -* Always use braces for control structures \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c11d571..e14a92d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.0...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.1...develop) + +## [v13.35.1](https://github.com/Pollora/framework/compare/v13.35.0...v13.35.1) - 2026-10-07 + +### Security +- WordPress 7.1.3 is a security release (seven fixes, among them a stored XSS on the Comments screen and a second-order SQL injection in the WXR export). The skeleton installs it from v13.35.1; an existing project runs `composer update johnpbloch/wordpress johnpbloch/wordpress-core`. Pollora's core patch still applies + +### Removed +- Editor and tool files no longer tracked: `.cursor/` and `.windsurf/` rules from 2025 (superseded by `CLAUDE.md` and Nectar), a `.claude/` plan, a 2023 `.php-cs-fixer.cache`, and the unused StyleCI and Code Climate configurations (style is checked by Pint in CI, coverage by Codecov) + +### Fixed +- `pollora:make:plugin` no longer copies the template's `bin/` (its packaging script and tests) into the new plugin, as `pollora:make:theme` already did +- A plugin that answers from `template_redirect` and includes the theme's query template itself (`include get_query_template('404')`, WooCommerce's Review Order page) printed the Blade source of the view: the `{type}_template` filters now hand back a loader that renders it while `template_redirect` runs (#419) +- `template_redirect` ran before the application's providers had booted — WordPress is loaded from a provider's boot, the theme's providers boot after it — so a view rendered from it lacked the theme's view composers and shared data. It now runs once every provider has booted, still before routing, and `pollora_loaded` after it (#419) +- Trackbacks answered 500: `wp-trackback.php` was required from a method, where it found no `$wp` and loaded WordPress a second time (#419) + +### Changed +- README: the organization's banner and badges (CI instead of Codecov), installation first, the newer features listed (typed meta, roles, blocks and Block Bindings, `pollora:doctor`), and the type coverage check under Testing ## [v13.35.0](https://github.com/Pollora/framework/compare/v13.34.6...v13.35.0) - 2026-10-06 diff --git a/LICENSE b/LICENSE index 4c0ea401..bc3cb937 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 RuBee group +Copyright (c) 2025 RuBee group (https://rubee.group) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b44ce5c8..d6883d3f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,40 @@

- Pollora + Pollora: The Laravel framework for WordPress

- Latest Version + Latest Version Total Downloads - Coverage + CI License

-## About Pollora - -**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. +**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, so `functions.php` stops being the place where everything piles up. [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) +## 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 +``` + +Requirements: + +- PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3) +- Laravel 13.35 (Pollora's version numbers follow the Laravel release it is built on) +- WordPress 7.1+ + +## Quick start + ```php use Pollora\Attributes\Filter; use Pollora\Attributes\PostType; @@ -35,40 +53,25 @@ class Seo } ``` -### Key Features +## Key features - **WordPress routing** via `Route::wp()` with template hierarchy support -- **PHP attributes** for hooks (`#[Action]`, `#[Filter]`), post types (`#[PostType]`), taxonomies (`#[Taxonomy]`), scheduling (`#[Schedule]`), and REST routes (`#[WpRestRoute]`) +- **PHP attributes** for hooks (`#[Action]`, `#[Filter]`), post types (`#[PostType]`), taxonomies (`#[Taxonomy]`), scheduling (`#[Schedule]`), REST routes (`#[WpRestRoute]`), AJAX handlers (`#[Ajax]`), WP-CLI commands (`#[WpCli]`) and abilities (`#[Ability]`) +- **Typed meta and roles** declared in code: `#[Meta]`, `#[PostMeta]`, `#[TermMeta]`, `#[Role]`, `#[ModifyRole]` +- **Gutenberg blocks** rendered with Blade, and Block Bindings sources for meta and options - **Auto-discovery system** that scans and registers components automatically - **Blade templates** with [Sage Directives](https://log1x.github.io/sage-directives-docs/) for WordPress data - **Theme system** with dynamic PSR-4 autoloading, parent/child theme support, and Vite asset management - **WordPress authentication** guard and password hashing integration - **Event dispatching** for WordPress core, WooCommerce, Gravity Forms, and Yoast SEO - **Module system** via [nwidart/laravel-modules](https://github.com/nwidart/laravel-modules) +- **`pollora:doctor`**, which finds the setups that fail without an error and prints the fix (also in Tools › Site Health) ## Documentation -Full documentation is available at **[pollora.dev](https://pollora.dev)**. - -## 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 -``` - -## Requirements - -- PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3) -- Laravel 13.35 (Pollora's version numbers follow the Laravel release it is built on) -- WordPress 7.1+ +Full documentation is available at **[pollora.dev](https://pollora.dev/getting-started/installation/)**. -## Learn more +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/) @@ -78,21 +81,14 @@ composer create-project pollora/pollora my-project ## Testing ```bash -composer test # Run all checks (Rector, Pint, PHPStan, Pest) -composer test:unit # Run Pest tests with 100% coverage requirement -composer test:types # Run PHPStan static analysis -composer test:lint # Check code style with Pint -composer test:refacto # Check refactoring rules with Rector +composer test # Run all checks (Rector, Pint, PHPStan, Pest, type coverage) +composer test:unit # Run Pest tests with 100% coverage requirement +composer test:types # Run PHPStan static analysis +composer test:lint # Check code style with Pint +composer test:refacto # Check refactoring rules with Rector +composer test:type-coverage # Check type coverage (98% minimum) ``` -## Contributing - -Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. - -## Security - -If you discover a security vulnerability, please report it via [GitHub Security Advisories](https://github.com/Pollora/framework/security/advisories/new). See [SECURITY.md](SECURITY.md) for details. - ## Changelog All notable changes are documented in [CHANGELOG.md](CHANGELOG.md). @@ -101,8 +97,12 @@ All notable changes are documented in [CHANGELOG.md](CHANGELOG.md). 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). +## Contributing + +Contributions are welcome: see the [contributing guide](CONTRIBUTING.md). Report security issues privately, as described in the [security policy](SECURITY.md). + ## License -Pollora is open-sourced software licensed under the [MIT license](LICENSE). +Pollora is open-source software licensed under the [MIT license](LICENSE). © [RuBee group](https://rubee.group) Parts of the WordPress integration (`Pollora\Support\WordPress`, `Pollora\Hashing\WordPressHasher`) originate from work by Jordan Doyle, released under the 0BSD and WTFPL licenses. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3eaa163e..f75c76db 100755 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -222,6 +222,12 @@ parameters: count: 1 path: src/ThirdParty/WooCommerce/Infrastructure/Services/WooCommerce.php + - + message: '#^Call to an undefined method Illuminate\\Contracts\\View\\View\:\:makeLoader\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/View/Application/UseCases/MakeTemplateIncludableUseCase.php + - message: '#^Constant WP_CLI_VERSION not found\.$#' identifier: constant.notFound diff --git a/rector.php b/rector.php index 1170f499..bfb229f9 100644 --- a/rector.php +++ b/rector.php @@ -32,9 +32,10 @@ // $_SERVER directly is the point rather than an oversight. ServerVariableToRequestFacadeRector::class => [ __DIR__.'/src/WordPress/QueryTrait.php', - // Its test sets $_SERVER['SCRIPT_FILENAME'] to drive that method; + // Its tests set $_SERVER['SCRIPT_FILENAME'] to drive that method; // going through the facade would stop it reaching the code at all. __DIR__.'/tests/Unit/WordPress/LaravelServingRequestTest.php', + __DIR__.'/tests/Unit/WordPress/DeferredTemplateRedirectTest.php', ], __DIR__.'/tests/Unit/helpers.php', // Binding sources each broken on purpose (an untyped field, a private diff --git a/src/Plugin/UI/Console/MakePluginCommand.php b/src/Plugin/UI/Console/MakePluginCommand.php index 1ce44077..39fb88a3 100644 --- a/src/Plugin/UI/Console/MakePluginCommand.php +++ b/src/Plugin/UI/Console/MakePluginCommand.php @@ -100,6 +100,7 @@ public function handle(): int version: $this->option('repo-version'), output: $this->getOutput(), fileFilter: fn (object $item): bool => ! $this->shouldExcludeAssetFile($item), + removeDirs: ['bin'], ); if (! $success && ! $this->scaffoldFromBundledTemplate()) { diff --git a/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php b/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php new file mode 100644 index 00000000..c000b861 --- /dev/null +++ b/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php @@ -0,0 +1,48 @@ +templateFinder->getViewNameFromPath($realPath), '\\/.'); + + if ($viewName === '' || ! $this->viewFactory->exists($viewName)) { + return $templatePath; + } + + return $this->viewFactory->make($viewName)->makeLoader(); + } +} diff --git a/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php b/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php index d8b93edf..cdc5c76f 100644 --- a/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php +++ b/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php @@ -64,6 +64,13 @@ public function execute(): void $this->hierarchyFilter->extendTemplateHierarchy(...), 10 ); + + // `404_template`… : for plugins that include get_query_template() themselves + $this->filter->add( + str_replace('_hierarchy', '', $filterName), + $this->hierarchyFilter->includableTemplate(...), + PHP_INT_MAX + ); } // Theme templates filter diff --git a/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php b/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php index 9ba6af79..3a189a5e 100644 --- a/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php +++ b/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php @@ -28,6 +28,16 @@ public function extendTemplateHierarchy(array $files): array; */ public function resolveTemplateInclude(string $template): string; + /** + * The template a `{type}_template` filter hands back: a file PHP can + * include while `template_redirect` runs, when a plugin includes the + * query template itself instead of going through `template_include`. + * + * @param string $template Template path WordPress resolved + * @return string Template path to use (original or a loader) + */ + public function includableTemplate(string $template): string; + /** * Add Blade compatibility for theme templates. * diff --git a/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php b/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php index e6250011..7782e443 100644 --- a/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php +++ b/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php @@ -10,6 +10,7 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; use Pollora\View\Application\Services\TemplateHierarchyService; +use Pollora\View\Application\UseCases\MakeTemplateIncludableUseCase; use Pollora\View\Application\UseCases\RegisterTemplateHierarchyFiltersUseCase; use Pollora\View\Application\UseCases\ResolveBladeTemplateUseCase; use Pollora\View\Domain\Contracts\TemplateFinderInterface; @@ -63,7 +64,8 @@ private function registerDomainContracts(): void $this->app->bind(TemplateHierarchyFilterInterface::class, fn (Application $app): WordPressTemplateHierarchyFilter => new WordPressTemplateHierarchyFilter( $app->make(TemplateFinderInterface::class), $app->make(ResolveBladeTemplateUseCase::class), - $app->get('view')->getFinder() + $app->get('view')->getFinder(), + $app->make(MakeTemplateIncludableUseCase::class) )); } @@ -78,6 +80,12 @@ private function registerUseCases(): void $app->get('view') )); + // Make Template Includable Use Case + $this->app->bind(MakeTemplateIncludableUseCase::class, fn (Application $app): MakeTemplateIncludableUseCase => new MakeTemplateIncludableUseCase( + $app->make(TemplateFinderInterface::class), + $app->get('view') + )); + // Register Template Hierarchy Filters Use Case $this->app->bind(RegisterTemplateHierarchyFiltersUseCase::class, fn (Application $app): RegisterTemplateHierarchyFiltersUseCase => new RegisterTemplateHierarchyFiltersUseCase( $app->make(Filter::class), diff --git a/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php b/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php index 4892da8e..6be88187 100644 --- a/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php +++ b/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Illuminate\View\FileViewFinder; use Illuminate\View\ViewFinderInterface; +use Pollora\View\Application\UseCases\MakeTemplateIncludableUseCase; use Pollora\View\Application\UseCases\ResolveBladeTemplateUseCase; use Pollora\View\Domain\Contracts\TemplateFinderInterface; use Pollora\View\Domain\Contracts\TemplateHierarchyFilterInterface; @@ -23,7 +24,8 @@ class WordPressTemplateHierarchyFilter implements TemplateHierarchyFilterInterfa public function __construct( private readonly TemplateFinderInterface $templateFinder, private readonly ResolveBladeTemplateUseCase $resolveBladeTemplateUseCase, - private readonly ViewFinderInterface $viewFinder + private readonly ViewFinderInterface $viewFinder, + private readonly MakeTemplateIncludableUseCase $makeTemplateIncludable ) {} /** @@ -56,6 +58,23 @@ public function resolveTemplateInclude(string $template): string return $this->resolveBladeTemplateUseCase->execute($template); } + /** + * A plugin that answers from `template_redirect` may include the query + * template itself (`include get_query_template('404')`, WooCommerce's + * Review Order page): it gets a loader that renders the view. Outside + * `template_redirect` the path is left alone — the FrontendController + * resolves the hierarchy later and needs the Blade path, and WordPress's + * own template loader must still meet the raw-output guard. + */ + public function includableTemplate(string $template): string + { + if (! function_exists('doing_action') || ! doing_action('template_redirect')) { + return $template; + } + + return $this->makeTemplateIncludable->execute($template); + } + /** * Add Blade compatibility for theme templates. */ diff --git a/src/WordPress/QueryTrait.php b/src/WordPress/QueryTrait.php index 37758924..abcd9030 100644 --- a/src/WordPress/QueryTrait.php +++ b/src/WordPress/QueryTrait.php @@ -5,6 +5,7 @@ namespace Pollora\WordPress; use Exception; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -75,11 +76,15 @@ private function isWordPressInstalled(): bool * Run the WordPress bootstrap process. * * This method initializes WordPress and handles special request types - * like robots.txt, favicon, feeds, trackbacks, and AJAX requests. AJAX requests - * are handled early and bypass the normal WordPress query processing to prevent - * them from reaching the Laravel routing system. Normal template resolution + * like robots.txt, favicon, feeds and trackbacks. Normal template resolution * is delegated to the Laravel routing system. * + * `template_redirect` runs once every provider has booted, still before + * routing: WordPress is loaded from this provider's boot, and the theme's + * providers, registered by functions.php, boot after it. A plugin that + * renders a page from `template_redirect` (WooCommerce's Review Order 404) + * then gets the theme's view composers and shared data. + * * @throws \RuntimeException If WordPress core functions are not available */ protected function runWp(): void @@ -88,41 +93,66 @@ protected function runWp(): void throw new \RuntimeException('The WordPress core functions are not available. Ensure WordPress is loaded.'); } - if ($this->laravelIsServingTheRequest()) { - // Initialize WordPress for the current request - wp(); - - if (wp_using_themes()) { - $this->action->do('template_redirect'); - } - - // Handle special request types that should bypass Laravel routing - if (is_robots()) { - $this->action->do('do_robots'); - exit; - } - - if (is_favicon()) { - $this->action->do('do_favicon'); - exit; - } - - if (is_feed()) { - do_feed(); - exit; - } - - if (is_trackback()) { - require_once ABSPATH.'wp-trackback.php'; - exit; - } + if (! $this->laravelIsServingTheRequest()) { + $this->action->do('pollora_loaded'); + + return; + } + + // Initialize WordPress for the current request + wp(); + + App::booted(function (): void { + $this->withWordPressErrorHandling(function (): void { + $this->handleTemplateRedirect(); + + // For normal requests, let Laravel routing handle template resolution + // Do not load WordPress template-loader.php as we use FrontendController instead + $this->action->do('pollora_loaded'); + }); + }); + } + + /** + * Fire `template_redirect`, then answer the requests WordPress serves itself. + */ + private function handleTemplateRedirect(): void + { + if (wp_using_themes()) { + $this->action->do('template_redirect'); + } + + // Handle special request types that should bypass Laravel routing + if (is_robots()) { + $this->action->do('do_robots'); + exit; + } + + if (is_favicon()) { + $this->action->do('do_favicon'); + exit; + } + + if (is_feed()) { + do_feed(); + exit; } - // For normal requests, let Laravel routing handle template resolution - // Do not load WordPress template-loader.php as we use FrontendController instead - $this->action->do('pollora_loaded'); + if (is_trackback()) { + // wp-trackback.php is written for the global scope: without $wp it + // loads WordPress a second time, and it reads $posts and $wpdb. + global $wp, $wpdb, $posts; + + require_once ABSPATH.'wp-trackback.php'; + exit; + } } + /** + * Run a callback with WordPress-safe error handling. + */ + abstract private function withWordPressErrorHandling(callable $callback): void; + /** * Whether this request is Laravel's to serve. * diff --git a/tests/Feature/Plugin/MakePluginTemplateToolingTest.php b/tests/Feature/Plugin/MakePluginTemplateToolingTest.php new file mode 100644 index 00000000..9f854d5f --- /dev/null +++ b/tests/Feature/Plugin/MakePluginTemplateToolingTest.php @@ -0,0 +1,30 @@ +set('plugin.path', $pluginsPath); + + $scaffolder = Mockery::mock(ModuleScaffolderService::class); + $scaffolder->shouldReceive('downloadAndScaffold') + ->once() + ->withArgs(fn (...$arguments): bool => ($arguments['removeDirs'] ?? $arguments[7] ?? null) === ['bin']) + ->andReturnTrue(); + $this->app->instance(ModuleScaffolderService::class, $scaffolder); + + $command = $this->app->make(MakePluginCommand::class); + $command->setLaravel($this->app); + + $input = new ArrayInput(['name' => 'acme', '--repository' => 'pollora/plugin-default']); + $input->setInteractive(false); + + expect($command->run($input, new BufferedOutput))->toBe(0); + }); +}); diff --git a/tests/Unit/View/Application/MakeTemplateIncludableTest.php b/tests/Unit/View/Application/MakeTemplateIncludableTest.php new file mode 100644 index 00000000..4719e0a2 --- /dev/null +++ b/tests/Unit/View/Application/MakeTemplateIncludableTest.php @@ -0,0 +1,90 @@ +dir = sys_get_temp_dir().'/pollora-includable-'.bin2hex(random_bytes(6)); + mkdir($this->dir, 0777, true); + touch($this->dir.'/404.blade.php'); + touch($this->dir.'/404.php'); + + $this->finder = Mockery::mock(TemplateFinderInterface::class); + $this->finder->allows('getViewNameFromPath')->andReturn('404'); + + $view = Mockery::mock(View::class); + $view->allows('makeLoader')->andReturn('/compiled/abc-loader.php'); + + $this->views = Mockery::mock(ViewFactory::class); + $this->views->allows('exists')->with('404')->andReturnTrue(); + $this->views->allows('make')->with('404')->andReturn($view); + + $this->useCase = new MakeTemplateIncludableUseCase($this->finder, $this->views); +}); + +afterEach(function (): void { + exec('rm -rf '.escapeshellarg($this->dir)); +}); + +it('hands back the loader of a Blade view', function (): void { + expect($this->useCase->execute($this->dir.'/404.blade.php'))->toBe('/compiled/abc-loader.php'); +}); + +it('leaves a PHP template, a missing file and an unknown view alone', function (): void { + expect($this->useCase->execute($this->dir.'/404.php'))->toBe($this->dir.'/404.php') + ->and($this->useCase->execute($this->dir.'/gone.blade.php'))->toBe($this->dir.'/gone.blade.php'); + + $views = Mockery::mock(ViewFactory::class); + $views->allows('exists')->andReturnFalse(); + + expect((new MakeTemplateIncludableUseCase($this->finder, $views))->execute($this->dir.'/404.blade.php'))->toBe($this->dir.'/404.blade.php'); +}); + +it('makes a template includable only while template_redirect runs', function (bool $redirecting, string $expected): void { + Functions\when('doing_action')->alias(fn (string $hook): bool => $redirecting && $hook === 'template_redirect'); + + $filter = new WordPressTemplateHierarchyFilter( + $this->finder, + Mockery::mock(ResolveBladeTemplateUseCase::class), + Mockery::mock(ViewFinderInterface::class), + $this->useCase, + ); + + expect($filter->includableTemplate($this->dir.'/404.blade.php'))->toBe($expected === 'loader' ? '/compiled/abc-loader.php' : $this->dir.'/404.blade.php'); +})->with([ + 'during template_redirect' => [true, 'loader'], + // The FrontendController resolves the hierarchy later and needs the Blade path. + 'afterwards' => [false, 'path'], +]); + +it('filters every query template last', function (): void { + $added = []; + $filter = Mockery::mock(Filter::class); + $filter->allows('add')->andReturnUsing(function (string $hook, callable $callback, int $priority = 10) use (&$added, $filter): Filter { + $added[$hook] = $priority; + + return $filter; + }); + + (new RegisterTemplateHierarchyFiltersUseCase($filter, Mockery::mock(TemplateHierarchyFilterInterface::class)->shouldIgnoreMissing()))->execute(); + + expect($added)->toHaveKey('404_template', PHP_INT_MAX) + ->and($added)->toHaveKey('single_template', PHP_INT_MAX) + ->and($added)->toHaveKey('404_template_hierarchy', 10); +}); diff --git a/tests/Unit/WordPress/DeferredTemplateRedirectTest.php b/tests/Unit/WordPress/DeferredTemplateRedirectTest.php new file mode 100644 index 00000000..9360767f --- /dev/null +++ b/tests/Unit/WordPress/DeferredTemplateRedirectTest.php @@ -0,0 +1,130 @@ +originalScript = $_SERVER['SCRIPT_FILENAME'] ?? null; + $this->originalFacadeApp = Facade::getFacadeApplication(); + + // No script to compare against: the request is Laravel's. + unset($_SERVER['SCRIPT_FILENAME']); + + $this->bootedCallbacks = []; + $callbacks = &$this->bootedCallbacks; + $app = new Container; + $app->instance('app', new class($callbacks) + { + public function __construct(private array &$callbacks) {} + + public function booted(callable $callback): void + { + $this->callbacks[] = $callback; + } + }); + Facade::clearResolvedInstance('app'); + Facade::setFacadeApplication($app); + + $this->fired = []; + $fired = &$this->fired; + $action = Mockery::mock(Action::class); + $this->action = $action; + $this->action->allows('do')->andReturnUsing(function (string $hook) use (&$fired, $action): Action { + $fired[] = $hook; + + return $action; + }); + + Functions\when('wp')->alias(function () use (&$fired): void { + $fired[] = 'wp'; + }); + Functions\stubs(['wp_using_themes' => true, 'is_robots' => false, 'is_favicon' => false, 'is_feed' => false, 'is_trackback' => false]); +}); + +afterEach(function (): void { + Facade::clearResolvedInstance('app'); + Facade::setFacadeApplication($this->originalFacadeApp); + + if ($this->originalScript !== null) { + $_SERVER['SCRIPT_FILENAME'] = $this->originalScript; + } +}); + +function wordPressRunner(Action $action): object +{ + return new class($action) + { + use QueryTrait; + + public int $guarded = 0; + + public function __construct(protected Action $action) {} + + public function run(): void + { + $this->runWp(); + } + + private function withWordPressErrorHandling(callable $callback): void + { + $this->guarded++; + $callback(); + } + }; +} + +it('runs the query at once and template_redirect once every provider has booted', function (): void { + $runner = wordPressRunner($this->action); + + $runner->run(); + + expect($this->fired)->toBe(['wp']) + ->and($this->bootedCallbacks)->toHaveCount(1); + + ($this->bootedCallbacks[0])(); + + expect($this->fired)->toBe(['wp', 'template_redirect', 'pollora_loaded']) + ->and($runner->guarded)->toBe(1); +}); + +it('fires only pollora_loaded when WordPress runs one of its own entry points', function (): void { + $dir = sys_get_temp_dir().'/pollora-entry-'.bin2hex(random_bytes(6)); + mkdir($dir.'/public/cms', 0777, true); + touch($dir.'/public/index.php'); + touch($dir.'/public/cms/wp-login.php'); + $_SERVER['SCRIPT_FILENAME'] = $dir.'/public/cms/wp-login.php'; + + // public_path() asks the application for the front controller, absent here. + $original = Container::getInstance(); + $laravel = new class extends Container + { + public string $base = ''; + + public function publicPath(string $path = ''): string + { + return $this->base.'/'.$path; + } + }; + $laravel->base = $dir.'/public'; + Container::setInstance($laravel); + + try { + wordPressRunner($this->action)->run(); + } finally { + Container::setInstance($original); + exec('rm -rf '.escapeshellarg($dir)); + } + + expect($this->fired)->toBe(['pollora_loaded']) + ->and($this->bootedCallbacks)->toBe([]); +}); diff --git a/tests/Unit/WordPress/LaravelServingRequestTest.php b/tests/Unit/WordPress/LaravelServingRequestTest.php index ad9a84fb..bd58f639 100644 --- a/tests/Unit/WordPress/LaravelServingRequestTest.php +++ b/tests/Unit/WordPress/LaravelServingRequestTest.php @@ -26,6 +26,11 @@ public function serving(): bool return $method->invoke($this); } + + private function withWordPressErrorHandling(callable $callback): void + { + $callback(); + } }; } diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php b/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php new file mode 100644 index 00000000..5bec21b5 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php @@ -0,0 +1,35 @@ + { await context.close(); }); }); + +test.describe('Requests answered from template_redirect', () => { + // A plugin that includes the theme's 404 template itself, as WooCommerce's Review + // Order page does (#419): the Blade view renders, with the theme's layout. + test("a plugin including the theme's 404 template gets the rendered view", async ({ page }) => { + const response = await page.goto(homeUrl('/?e2e-template-redirect=1')); + + expect(response?.status()).toBe(404); + expect(await page.content()).not.toMatch(/@extends|@section|\{\{--/); + // wp_footer ran, so the layout did. + await expect(page.locator('script#e2e-features')).toHaveCount(1); + }); + + test('a trackback is answered and stored', async () => { + const post = wp('post', 'create', '--post_status=publish', '--ping_status=open', '--porcelain', `--post_title=Trackback ${runId}`); + const context = await visitor(); + + try { + const response = await context.post(homeUrl(`/?p=${post}&tb=1`), { + form: { url: `https://example.test/${runId}`, title: 'Trackback', excerpt: 'Excerpt', blog_name: 'E2E' }, + }); + + expect(response.status()).toBe(200); + expect(await response.text()).toContain('0'); + expect(wp('comment', 'list', `--post_id=${post}`, '--type=trackback', '--format=count')).toBe('1'); + } finally { + await context.dispose(); + wp('post', 'delete', post, '--force'); + } + }); +});