diff --git a/src/Asset/Application/Services/AssetManager.php b/src/Asset/Application/Services/AssetManager.php index ffad0c2a..5d7bef2d 100755 --- a/src/Asset/Application/Services/AssetManager.php +++ b/src/Asset/Application/Services/AssetManager.php @@ -4,6 +4,9 @@ namespace Pollora\Asset\Application\Services; +use Closure; +use Pollora\Asset\Domain\Contracts\PublicPath; +use Pollora\Asset\Domain\Exceptions\AssetException; use Pollora\Asset\Domain\Models\Asset; use Pollora\Asset\Infrastructure\Repositories\AssetContainer; use Pollora\Asset\Infrastructure\Services\AssetEnqueuer; @@ -33,8 +36,14 @@ class AssetManager * Initializes the asset manager with the retrieval service. * * @param AssetRetrievalService $retrievalService Service for retrieving assets + * @param PublicPath|null $publicPath Where the hot file of a container without one lives + * @param (Closure(): AssetEnqueuer)|null $enqueuers Builds the enqueuer of an asset */ - public function __construct(private readonly AssetRetrievalService $retrievalService) {} + public function __construct( + private readonly AssetRetrievalService $retrievalService, + private readonly ?PublicPath $publicPath = null, + private readonly ?Closure $enqueuers = null, + ) {} /** * Factory method: returns an AssetEnqueuer (builder) for fluent asset management and enqueueing. @@ -45,7 +54,11 @@ public function __construct(private readonly AssetRetrievalService $retrievalSer */ public function add(string $handle, string $file): AssetEnqueuer { - return resolve(AssetEnqueuer::class) + if (! $this->enqueuers instanceof Closure) { + throw new AssetException('No asset enqueuer is set: the asset manager was built without one.'); + } + + return ($this->enqueuers)() ->handle($handle) ->path($file); } @@ -79,6 +92,10 @@ public function all(): array */ public function addContainer(string $name, array $config): void { + if (! isset($config['hot_file']) && $this->publicPath instanceof PublicPath) { + $config['hot_file'] = $this->publicPath->path($name.'.hot'); + } + $this->containers[$name] = new AssetContainer($name, $config); } diff --git a/src/Asset/Application/Services/MissingBuilds.php b/src/Asset/Application/Services/MissingBuilds.php index 69705794..b38ca17a 100644 --- a/src/Asset/Application/Services/MissingBuilds.php +++ b/src/Asset/Application/Services/MissingBuilds.php @@ -4,7 +4,8 @@ namespace Pollora\Asset\Application\Services; -use Illuminate\Support\Facades\Log; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * The asset containers asked for assets this request whose Vite build is missing. @@ -17,6 +18,8 @@ */ class MissingBuilds { + public function __construct(private readonly ?LoggerInterface $logger = null) {} + /** * Manifest path of each container whose build is missing, by container name. * @@ -32,7 +35,7 @@ public function record(string $container, string $manifest): void $this->missing[$container] = $manifest; - Log::warning(sprintf('Pollora: the "%s" assets are not built, no Vite manifest at %s. Its scripts and styles are left out until you run npm run build.', $container, $manifest), [ + ($this->logger ?? new NullLogger)->warning(sprintf('Pollora: the "%s" assets are not built, no Vite manifest at %s. Its scripts and styles are left out until you run npm run build.', $container, $manifest), [ 'container' => $container, 'manifest' => $manifest, ]); diff --git a/src/Asset/Domain/Contracts/PublicPath.php b/src/Asset/Domain/Contracts/PublicPath.php new file mode 100644 index 00000000..8cec1830 --- /dev/null +++ b/src/Asset/Domain/Contracts/PublicPath.php @@ -0,0 +1,16 @@ +app->make(AssetManager::class)->getContainer($container); + + if (! $assetContainer instanceof AssetContainer) { + return ''; + } + + return $this->app->make(ViteManagerFactory::class)->make($assetContainer)->asset($file); + } catch (\Throwable $throwable) { + $this->app->make(LoggerInterface::class)->error('Error in AssetFile::__toString', [ + 'error' => $throwable->getMessage(), + 'path' => $file, + ]); + + return ''; + } + } +} diff --git a/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php b/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php new file mode 100644 index 00000000..e0c6cfb1 --- /dev/null +++ b/src/Asset/Infrastructure/Laravel/LaravelPublicPath.php @@ -0,0 +1,18 @@ +console->isConsole() || $this->console->isWpCli(); + } +} diff --git a/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php b/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php new file mode 100644 index 00000000..54d0fbdb --- /dev/null +++ b/src/Asset/Infrastructure/Laravel/LaravelViteManagerFactory.php @@ -0,0 +1,21 @@ +app->bind(AssetRepositoryInterface::class, InMemoryAssetRepository::class); + + // What the assets need from the environment: here, Laravel + $this->app->singleton(PublicPath::class, LaravelPublicPath::class); + $this->app->singleton(ViteManagerFactory::class, LaravelViteManagerFactory::class); + $this->app->singleton(RuntimeContext::class, fn (Application $app): RuntimeContext => new LaravelRuntimeContext($app->make(ConsoleDetectionService::class))); + $this->app->singleton(AssetManager::class, fn (Application $app): AssetManager => new AssetManager( - $app->make(AssetRetrievalService::class) + $app->make(AssetRetrievalService::class), + $app->make(PublicPath::class), + static fn (): AssetEnqueuer => $app->make(AssetEnqueuer::class), + )); + // Hooks come from pollora/hook's provider, absent from some test applications + $this->app->bind(AssetEnqueuer::class, fn (Application $app): AssetEnqueuer => new AssetEnqueuer( + $app->make(AssetManager::class), + $app->bound(Action::class) ? $app->make(Action::class) : null, + $app->bound(Filter::class) ? $app->make(Filter::class) : null, + $app->make(ViteManagerFactory::class), + $app->make(RuntimeContext::class), + $app->make(LoggerInterface::class), )); - $this->app->bind(AssetEnqueuer::class, fn (Application $app): AssetEnqueuer => new AssetEnqueuer($app)); + AssetFile::resolveUsing(Closure::fromCallable(new LaravelAssetFileResolver($this->app))); + $this->app->bind(MissingBuildNotice::class, fn (Application $app): MissingBuildNotice => new MissingBuildNotice($app->make(MissingBuilds::class), base_path())); $this->app->singleton(MissingBuilds::class); $this->app->singleton(RootAssetManager::class, fn (Application $app): RootAssetManager => new RootAssetManager( $app->make(AssetManager::class) diff --git a/src/Asset/Infrastructure/Repositories/AssetContainer.php b/src/Asset/Infrastructure/Repositories/AssetContainer.php index 8158b02a..8674e9b7 100755 --- a/src/Asset/Infrastructure/Repositories/AssetContainer.php +++ b/src/Asset/Infrastructure/Repositories/AssetContainer.php @@ -46,7 +46,7 @@ class AssetContainer */ public function __construct(protected string $name, protected array $config = []) { - $this->hotFile = $config['hot_file'] ?? public_path($this->name.'.hot'); + $this->hotFile = $config['hot_file'] ?? $this->name.'.hot'; $this->buildDirectory = $config['build_directory'] ?? 'build/'.$this->name; $this->manifestPath = $config['manifest_path'] ?? 'manifest.json'; $this->basePath = $config['base_path'] ?? ''; diff --git a/src/Asset/Infrastructure/Services/AssetEnqueuer.php b/src/Asset/Infrastructure/Services/AssetEnqueuer.php index f6ad6ab9..e1e974df 100755 --- a/src/Asset/Infrastructure/Services/AssetEnqueuer.php +++ b/src/Asset/Infrastructure/Services/AssetEnqueuer.php @@ -4,14 +4,16 @@ namespace Pollora\Asset\Infrastructure\Services; -use Illuminate\Foundation\Application; -use Illuminate\Support\Facades\Log; -use Pollora\Application\Application\Services\ConsoleDetectionService; use Pollora\Asset\Application\Services\AssetManager; +use Pollora\Asset\Domain\Contracts\RuntimeContext; +use Pollora\Asset\Domain\Contracts\ViteManagerFactory; +use Pollora\Asset\Domain\Contracts\ViteManagerInterface; use Pollora\Asset\Domain\Exceptions\AssetException; use Pollora\Asset\Infrastructure\Repositories\AssetContainer; use Pollora\Hook\Domain\Contract\Action as HookAction; use Pollora\Hook\Domain\Contract\Filter as HookFilter; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Handles the registration and enqueuing of CSS and JavaScript assets in WordPress. @@ -64,7 +66,7 @@ class AssetEnqueuer /** * The Vite manager instance. */ - protected ?ViteManager $viteManager = null; + protected ?ViteManagerInterface $viteManager = null; /** * Whether to use Vite.js for asset handling. @@ -121,22 +123,40 @@ class AssetEnqueuer protected array $localizationData = []; /** - * AssetManager instance for container resolution. + * @param AssetManager $assetManager Resolves the containers by name + * @param HookAction|null $action Registers the enqueue callbacks on WordPress hooks + * @param HookFilter|null $filter Registers the tag and script attribute filters + * @param ViteManagerFactory|null $viteManagers Builds the Vite resolver of a container for useVite() + * @param RuntimeContext|null $runtime Tells the command line, where nothing is enqueued + * @param LoggerInterface|null $logger Where a failure of the deferred enqueue is reported */ - protected AssetManager $assetManager; + public function __construct( + protected AssetManager $assetManager, + protected ?HookAction $action = null, + protected ?HookFilter $filter = null, + protected ?ViteManagerFactory $viteManagers = null, + protected ?RuntimeContext $runtime = null, + protected ?LoggerInterface $logger = null, + ) {} /** - * Console detection service instance. + * The hook service the enqueuer registers on. + * + * @throws AssetException When the enqueuer was built without one */ - protected ConsoleDetectionService $consoleDetectionService; + protected function hookAction(): HookAction + { + return $this->action ?? throw new AssetException('No hook service is set: the enqueuer was built without an Action.'); + } /** - * Constructor with dependency injection. + * The filter service the enqueuer registers on. + * + * @throws AssetException When the enqueuer was built without one */ - public function __construct(Application $app) + protected function hookFilter(): HookFilter { - $this->assetManager = $app->make(AssetManager::class); - $this->consoleDetectionService = $app->make(ConsoleDetectionService::class); + return $this->filter ?? throw new AssetException('No filter service is set: the enqueuer was built without a Filter.'); } /** @@ -171,7 +191,7 @@ public function path(string $path): self */ public function container(string $containerName): self { - if ($this->consoleDetectionService->isConsole() || $this->consoleDetectionService->isWpCli()) { + if ($this->runtime?->isCommandLine()) { return $this; } @@ -204,7 +224,7 @@ public function dependencies(array $dependencies): self */ public function useVite(): self { - if ($this->consoleDetectionService->isConsole() || $this->consoleDetectionService->isWpCli()) { + if ($this->runtime?->isCommandLine()) { return $this; } @@ -213,7 +233,11 @@ public function useVite(): self } $this->useVite = true; - $this->viteManager = new ViteManager($this->container); + if (! $this->viteManagers instanceof ViteManagerFactory) { + throw new AssetException('No Vite resolver is set: the enqueuer was built without a ViteManagerFactory.'); + } + + $this->viteManager = $this->viteManagers->make($this->container); return $this; } @@ -351,10 +375,27 @@ public function __destruct() $this->loadViteClient($hook); } - resolve(HookAction::class)->add($hook, fn () => $this->enqueueStyleOrScript($hook), 99); + $this->hookAction()->add($hook, fn () => $this->enqueueStyleOrScript($hook), 99); } } catch (\Throwable $throwable) { - Log::error('Error in AssetEnqueuer::__destruct', ['error' => $throwable->getMessage(), 'hooks' => $this->hooks, 'path' => $this->path ?? null]); + $this->report('Error in AssetEnqueuer::__destruct', ['error' => $throwable->getMessage(), 'hooks' => $this->hooks, 'path' => $this->path ?? null]); + } + } + + /** + * Report a failure of the deferred enqueue. + * + * Called from the destructor, which may run when the application is already gone + * (end of the request, a test that built the enqueuer): a logger that cannot log then must not throw. + * + * @param array $context + */ + private function report(string $message, array $context): void + { + try { + ($this->logger ?? new NullLogger)->error($message, $context); + } catch (\Throwable) { + // Nothing left to report to } } @@ -391,8 +432,8 @@ protected function loadViteClient(string $hook): void { if ($this->printsScriptModules($hook)) { // The client is a module too: printed ahead of the import map, it would void it. - resolve(HookAction::class)->add($hook, function (): void { - $url = $this->viteManager instanceof ViteManager ? $this->viteManager->clientUrl() : ''; + $this->hookAction()->add($hook, function (): void { + $url = $this->viteManager instanceof ViteManagerInterface ? $this->viteManager->clientUrl() : ''; if ($url !== '') { // No version: the modules Vite serves import /@vite/client by its bare URL, @@ -404,8 +445,8 @@ protected function loadViteClient(string $hook): void return; } - resolve(HookAction::class)->add($hook, function (): void { - if ($this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot()) { + $this->hookAction()->add($hook, function (): void { + if ($this->viteManager instanceof ViteManagerInterface && $this->viteManager->isRunningHot()) { echo $this->viteManager->getViteClientHtml(); } }, 1); @@ -418,7 +459,7 @@ protected function loadViteClient(string $hook): void */ public function configureViteAssets(): void { - if (! $this->viteManager instanceof ViteManager) { + if (! $this->viteManager instanceof ViteManagerInterface) { throw new AssetException('Vite manager not initialized. Call useVite() first.'); } @@ -435,7 +476,7 @@ public function configureViteAssets(): void */ protected function needToLoadViteClient(): bool { - return $this->useVite && $this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot(); + return $this->useVite && $this->viteManager instanceof ViteManagerInterface && $this->viteManager->isRunningHot(); } /** @@ -521,7 +562,7 @@ protected function enqueueScriptModule(string $path, string $handle): void // exact URL — a chunk importing the entry back would load a second copy of it. wp_enqueue_script_module($handle, $path, [], $this->version, ['in_footer' => $this->loadInFooter]); - resolve(HookFilter::class)->add('wp_script_attributes', fn (array $attributes): array => ($attributes['id'] ?? null) === $handle.'-js-module' + $this->hookFilter()->add('wp_script_attributes', fn (array $attributes): array => ($attributes['id'] ?? null) === $handle.'-js-module' ? [...$attributes, 'crossorigin' => true] : $attributes); @@ -564,7 +605,7 @@ protected function enqueueStyle(string $path, string $handle): void */ protected function addViteScriptAttributes(string $handle): void { - resolve(HookFilter::class)->add('script_loader_tag', fn ($tag, $scriptHandle, $src) => $scriptHandle === $handle + $this->hookFilter()->add('script_loader_tag', fn ($tag, $scriptHandle, $src) => $scriptHandle === $handle ? '' : $tag, 10, 3); } diff --git a/src/Asset/Infrastructure/Services/AssetFile.php b/src/Asset/Infrastructure/Services/AssetFile.php index 59408ad0..b77e1d8f 100755 --- a/src/Asset/Infrastructure/Services/AssetFile.php +++ b/src/Asset/Infrastructure/Services/AssetFile.php @@ -4,12 +4,9 @@ namespace Pollora\Asset\Infrastructure\Services; -use Illuminate\Foundation\Application; -use Illuminate\Support\Facades\Log; -use Pollora\Asset\Application\Services\AssetManager; +use Closure; use Pollora\Asset\Domain\Contracts\AssetFileInterface; use Pollora\Asset\Domain\Models\AssetFile as DomainAssetFile; -use Pollora\Asset\Infrastructure\Repositories\AssetContainer; /** * Infrastructure implementation for asset file URL resolution (Vite, containers, etc.). @@ -19,6 +16,25 @@ */ class AssetFile extends DomainAssetFile implements AssetFileInterface { + /** + * What turns a file and the name of its container into a URL, set by the environment. + * + * @var (Closure(string, string): string)|null + */ + private static ?Closure $resolver = null; + + /** + * Set what resolves the URL of an asset file, null to remove it. + * + * An AssetFile is built with `new` wherever a theme names a file, so what resolves it is global. + * + * @param (Closure(string, string): string)|null $resolver Receives the file and the container name + */ + public static function resolveUsing(?Closure $resolver): void + { + self::$resolver = $resolver; + } + /** * Converts the asset file to its URL string representation using infrastructure logic. * @@ -26,23 +42,6 @@ class AssetFile extends DomainAssetFile implements AssetFileInterface */ public function __toString(): string { - try { - Application::getInstance(); - /** @var AssetContainer|null $assetContainer */ - $assetContainer = resolve(AssetManager::class)->getContainer($this->assetContainer); - - if ($assetContainer === null) { - return ''; - } - - return (new ViteManager($assetContainer))->asset($this->filename); - } catch (\Throwable $throwable) { - Log::error('Error in AssetFile::__toString', [ - 'error' => $throwable->getMessage(), - 'path' => $this->filename, - ]); - - return ''; - } + return self::$resolver instanceof Closure ? (self::$resolver)($this->filename, $this->assetContainer) : ''; } } diff --git a/src/Asset/UI/Http/MissingBuildNotice.php b/src/Asset/UI/Http/MissingBuildNotice.php index 838b8237..b57099e3 100644 --- a/src/Asset/UI/Http/MissingBuildNotice.php +++ b/src/Asset/UI/Http/MissingBuildNotice.php @@ -14,7 +14,10 @@ */ final readonly class MissingBuildNotice { - public function __construct(private MissingBuilds $missingBuilds) {} + /** + * @param string $root The project root, taken off the manifest paths the notice shows; empty to show them whole + */ + public function __construct(private MissingBuilds $missingBuilds, private string $root = '') {} public function render(): void { @@ -58,7 +61,11 @@ public function render(): void private function relative(string $path): string { - $base = rtrim(base_path(), '/').'/'; + if ($this->root === '') { + return $path; + } + + $base = rtrim($this->root, '/').'/'; return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; } diff --git a/tests/Feature/Asset/AssetContainersContractTest.php b/tests/Feature/Asset/AssetContainersContractTest.php index d224f416..3cdfc665 100644 --- a/tests/Feature/Asset/AssetContainersContractTest.php +++ b/tests/Feature/Asset/AssetContainersContractTest.php @@ -6,6 +6,9 @@ use Pollora\Asset\Application\Services\AssetManager; use Pollora\Asset\Application\Services\AssetRetrievalService; use Pollora\Asset\Application\Services\MissingBuilds; +use Pollora\Asset\Domain\Contracts\ViteManagerFactory; +use Pollora\Asset\Infrastructure\Laravel\LaravelAssetFileResolver; +use Pollora\Asset\Infrastructure\Laravel\LaravelViteManagerFactory; use Pollora\Asset\Infrastructure\Repositories\AssetContainer; use Pollora\Asset\Infrastructure\Services\AssetFile; use Pollora\Asset\Infrastructure\Services\RootAssetManager; @@ -37,6 +40,8 @@ $this->assetManager = new AssetManager(Mockery::mock(AssetRetrievalService::class)); $this->app->instance(AssetManager::class, $this->assetManager); $this->app->singleton(MissingBuilds::class); + $this->app->singleton(ViteManagerFactory::class, LaravelViteManagerFactory::class); + AssetFile::resolveUsing(Closure::fromCallable(new LaravelAssetFileResolver($this->app))); $this->modules = new ModuleAssetManager($this->app); $this->modules->setupModuleAssetContainer($this->names['theme'], '/themes/apiary', 'theme'); @@ -62,6 +67,8 @@ }); afterEach(function (): void { + AssetFile::resolveUsing(null); + foreach (array_unique($this->builds) as $directory) { foreach ($this->names as $name) { (new Filesystem)->deleteDirectory($directory.'/'.$name); diff --git a/tests/Feature/Asset/AssetEnqueuerTest.php b/tests/Feature/Asset/AssetEnqueuerTest.php index de0fb38c..c7601ac0 100644 --- a/tests/Feature/Asset/AssetEnqueuerTest.php +++ b/tests/Feature/Asset/AssetEnqueuerTest.php @@ -6,6 +6,12 @@ use Pollora\Application\Application\Services\ConsoleDetectionService; use Pollora\Application\Domain\Contracts\ConsoleDetectorInterface; use Pollora\Asset\Application\Services\AssetManager; +use Pollora\Asset\Domain\Contracts\RuntimeContext; +use Pollora\Asset\Domain\Contracts\ViteManagerFactory; +use Pollora\Asset\Domain\Contracts\ViteManagerInterface; +use Pollora\Asset\Domain\Exceptions\AssetException; +use Pollora\Asset\Infrastructure\Laravel\LaravelRuntimeContext; +use Pollora\Asset\Infrastructure\Repositories\AssetContainer; use Pollora\Asset\Infrastructure\Services\AssetEnqueuer; use Pollora\Asset\Infrastructure\Services\ViteManager; use Pollora\Hook\Domain\Contract\Action as HookAction; @@ -19,6 +25,7 @@ $this->app->instance(ConsoleDetectorInterface::class, $detector); $this->app->singleton(ConsoleDetectionService::class, fn (): ConsoleDetectionService => new ConsoleDetectionService($detector)); + $this->app->singleton(RuntimeContext::class, fn (): RuntimeContext => new LaravelRuntimeContext(new ConsoleDetectionService($detector))); // Bind AssetManager $this->assetManager = Mockery::mock(AssetManager::class); @@ -228,6 +235,40 @@ }); }); + describe('the Vite resolver of useVite()', function (): void { + beforeEach(function (): void { + // Not the command line: useVite() acts + $this->app->singleton(RuntimeContext::class, fn (): RuntimeContext => new class implements RuntimeContext + { + public function isCommandLine(): bool + { + return false; + } + }); + + $this->container = new AssetContainer('theme', ['hot_file' => '/tmp/none.hot', 'build_directory' => 'build/theme/x']); + $this->assetManager->shouldReceive('getContainer')->with('theme')->andReturn($this->container); + }); + + it('asks the factory for the resolver of the container it was given', function (): void { + $resolver = Mockery::mock(ViteManagerInterface::class)->shouldIgnoreMissing(); + $factory = Mockery::mock(ViteManagerFactory::class); + $factory->shouldReceive('make')->once()->with($this->container)->andReturn($resolver); + $this->app->instance(ViteManagerFactory::class, $factory); + + $enqueuer = resolve(AssetEnqueuer::class)->handle('theme/script')->path('app.js')->container('theme')->useVite(); + + expect((new ReflectionProperty($enqueuer, 'viteManager'))->getValue($enqueuer))->toBe($resolver); + }); + + it('says so when no resolver is set', function (): void { + $this->app->forgetInstance(ViteManagerFactory::class); + + expect(fn () => resolve(AssetEnqueuer::class)->handle('theme/script')->path('app.js')->container('theme')->useVite()) + ->toThrow(AssetException::class, 'No Vite resolver is set'); + }); + }); + describe('Vite entries as script modules', function (): void { beforeEach(function (): void { $this->filters = []; diff --git a/tests/Feature/Asset/ViteManagerTest.php b/tests/Feature/Asset/ViteManagerTest.php index 6eebeab3..735edffd 100644 --- a/tests/Feature/Asset/ViteManagerTest.php +++ b/tests/Feature/Asset/ViteManagerTest.php @@ -132,6 +132,8 @@ describe('MissingBuildNotice', function (): void { beforeEach(function (): void { + // AssetServiceProvider's binding: manifest paths shown from the project root + app()->bind(MissingBuildNotice::class, fn (): MissingBuildNotice => new MissingBuildNotice(resolve(MissingBuilds::class), base_path())); Functions\when('current_user_can')->justReturn(true); Functions\when('esc_html')->returnArg(); Functions\when('esc_html__')->returnArg();