├── LICENSE ├── composer.json └── src ├── Assets.php ├── Context ├── Context.php └── WpContext.php ├── Enqueue ├── AbstractEnqueue.php ├── Collection.php ├── CssEnqueue.php ├── Enqueue.php ├── Filters.php ├── JsEnqueue.php └── Strategy.php ├── Factory.php ├── UrlResolver ├── DirectUrlResolver.php ├── ManifestUrlResolver.php ├── MinifyResolver.php └── UrlResolver.php ├── Utils ├── DependencyInfoExtractor.php └── PathFinder.php └── Version ├── LastModifiedVersion.php └── Version.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Giuseppe Mazzapica 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brain/assets", 3 | "description": "WordPress assets helpers.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Giuseppe Mazzapica", 9 | "email": "giuseppe.mazzapica@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "prefer-stable": true, 14 | "repositories": [ 15 | { 16 | "type": "composer", 17 | "url": "https://raw.githubusercontent.com/inpsyde/wp-stubs/main", 18 | "only": [ 19 | "inpsyde/wp-stubs-versions" 20 | ] 21 | } 22 | ], 23 | "require": { 24 | "php": ">= 8.0 < 8.4" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^9.6.16", 28 | "brain/monkey": "^2.6.1", 29 | "inpsyde/php-coding-standards": "^2@dev", 30 | "phpcompatibility/php-compatibility": "^10@dev", 31 | "vimeo/psalm": "^5.22.0", 32 | "inpsyde/wp-stubs-versions": "dev-latest" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Brain\\Assets\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Brain\\Assets\\Tests\\": "tests/src/", 42 | "Brain\\Assets\\Tests\\Unit\\": "tests/unit/" 43 | } 44 | }, 45 | "config": { 46 | "optimize-autoloader": true, 47 | "allow-plugins": { 48 | "composer/*": true, 49 | "dealerdirect/phpcodesniffer-composer-installer": true 50 | } 51 | }, 52 | "scripts": { 53 | "cs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs", 54 | "psalm": "@php ./vendor/vimeo/psalm/psalm --no-suggestions --report-show-info=false --find-unused-psalm-suppress --no-diff --no-cache --no-file-cache --output-format=compact", 55 | "tests": "@php ./vendor/phpunit/phpunit/phpunit --no-coverage", 56 | "qa": [ 57 | "@cs", 58 | "@psalm", 59 | "@tests" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Assets.php: -------------------------------------------------------------------------------- 1 | */ 32 | private array $subFolders = []; 33 | private bool $removalConfigured = false; 34 | 35 | /** 36 | * @param string $mainPluginFilePath 37 | * @param string $assetsDir 38 | * @return static 39 | */ 40 | public static function forPlugin(string $mainPluginFilePath, string $assetsDir = '/'): static 41 | { 42 | $context = Factory::factoryContextForPlugin($mainPluginFilePath, $assetsDir); 43 | 44 | return new static(Factory::new($context)); 45 | } 46 | 47 | /** 48 | * @param string $assetsDir 49 | * @return static 50 | */ 51 | public static function forTheme(string $assetsDir = '/'): static 52 | { 53 | return new static(Factory::new(Factory::factoryContextForTheme($assetsDir))); 54 | } 55 | 56 | /** 57 | * @param string $assetsDir 58 | * @param string|null $parentAssetsDir 59 | * @return static 60 | */ 61 | public static function forChildTheme( 62 | string $assetsDir = '/', 63 | ?string $parentAssetsDir = null 64 | ): static { 65 | 66 | $context = Factory::factoryContextForChildTheme($assetsDir, $parentAssetsDir); 67 | 68 | return new static(Factory::new($context)); 69 | } 70 | 71 | /** 72 | * @param non-empty-string $name 73 | * @param non-falsy-string $baseDir 74 | * @param non-falsy-string $baseUrl 75 | * @return static 76 | */ 77 | public static function forLibrary(string $name, string $baseDir, string $baseUrl): static 78 | { 79 | $context = Factory::factoryContextForLibrary($name, $baseDir, $baseUrl); 80 | 81 | return new static(Factory::new($context)); 82 | } 83 | 84 | /** 85 | * @param non-empty-string $name 86 | * @param non-falsy-string $manifestJsonPath 87 | * @param non-falsy-string $baseUrl 88 | * @param non-falsy-string|null $basePath 89 | * @param non-falsy-string|null $altBasePath 90 | * @param non-falsy-string|null $altBaseUrl 91 | * @return static 92 | */ 93 | public static function forManifest( 94 | string $name, 95 | string $manifestJsonPath, 96 | string $baseUrl, 97 | ?string $basePath = null, 98 | ?string $altBasePath = null, 99 | ?string $altBaseUrl = null 100 | ): static { 101 | 102 | $context = Factory::factoryContextForManifest( 103 | $name, 104 | $manifestJsonPath, 105 | $baseUrl, 106 | $basePath, 107 | $altBasePath, 108 | $altBaseUrl 109 | ); 110 | 111 | return new static(Factory::new($context)); 112 | } 113 | 114 | /** 115 | * @param Context\Context $context 116 | * @return static 117 | */ 118 | public static function forContext(Context\Context $context): static 119 | { 120 | return new static(Factory::new($context)); 121 | } 122 | 123 | /** 124 | * @param Factory $factory 125 | * @return static 126 | */ 127 | public static function forFactory(Factory $factory): static 128 | { 129 | return new static($factory); 130 | } 131 | 132 | /** 133 | * @param Factory $factory 134 | */ 135 | final protected function __construct(private Factory $factory) 136 | { 137 | // Store separately from name, so we can enable & disable as well as changing it. 138 | $this->handlePrefix = $this->context()->name(); 139 | $this->collection = Enqueue\Collection::new($this); 140 | } 141 | 142 | /** 143 | * @return Enqueue\Collection 144 | */ 145 | public function collection(): Enqueue\Collection 146 | { 147 | return clone $this->collection; 148 | } 149 | 150 | /** 151 | * @return string 152 | */ 153 | public function handlePrefix(): string 154 | { 155 | return $this->handlePrefix; 156 | } 157 | 158 | /** 159 | * @return static 160 | */ 161 | public function disableHandlePrefix(): static 162 | { 163 | $this->handlePrefix = ''; 164 | 165 | return $this; 166 | } 167 | 168 | /** 169 | * @param string|null $prefix 170 | * @return static 171 | */ 172 | public function enableHandlePrefix(?string $prefix = null): static 173 | { 174 | $this->handlePrefix = $prefix ?? $this->context()->name(); 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * @return static 181 | */ 182 | public function useDependencyExtractionData(): static 183 | { 184 | $this->useDepExtractionData = true; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * @return static 191 | */ 192 | public function dontUseDependencyExtractionData(): static 193 | { 194 | $this->useDepExtractionData = false; 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * @return static 201 | */ 202 | public function dontTryMinUrls(): static 203 | { 204 | $this->minifyResolver = null; 205 | 206 | return $this; 207 | } 208 | 209 | /** 210 | * @return static 211 | */ 212 | public function tryMinUrls(): static 213 | { 214 | $this->minifyResolver = $this->factory->minifyResolver(); 215 | 216 | return $this; 217 | } 218 | 219 | /** 220 | * @return static 221 | */ 222 | public function dontAddVersion(): static 223 | { 224 | $this->addVersion = false; 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * @return static 231 | */ 232 | public function forceDebug(): static 233 | { 234 | $this->factory->context()->enableDebug(); 235 | 236 | return $this; 237 | } 238 | 239 | /** 240 | * @return static 241 | */ 242 | public function forceNoDebug(): static 243 | { 244 | $this->factory->context()->disableDebug(); 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * @return static 251 | */ 252 | public function forceSecureUrls(): static 253 | { 254 | $this->forceSecureUrls = true; 255 | 256 | return $this; 257 | } 258 | 259 | /** 260 | * @return static 261 | */ 262 | public function dontForceSecureUrls(): static 263 | { 264 | $this->forceSecureUrls = false; 265 | 266 | return $this; 267 | } 268 | 269 | /** 270 | * @return Context\Context 271 | */ 272 | public function context(): Context\Context 273 | { 274 | return $this->factory->context(); 275 | } 276 | 277 | /** 278 | * @param string $path 279 | * @return static 280 | */ 281 | public function withCssFolder(string $path = 'css'): static 282 | { 283 | return $this->withSubfolder(self::CSS, $path, true); 284 | } 285 | 286 | /** 287 | * @param string $path 288 | * @return static 289 | */ 290 | public function withJsFolder(string $path = 'js'): static 291 | { 292 | return $this->withSubfolder(self::JS, $path, true); 293 | } 294 | 295 | /** 296 | * @param string $path 297 | * @return static 298 | */ 299 | public function withImagesFolder(string $path = 'images'): static 300 | { 301 | return $this->withSubfolder(self::IMAGE, $path, false); 302 | } 303 | 304 | /** 305 | * @param string $path 306 | * @return static 307 | */ 308 | public function withVideosFolder(string $path = 'videos'): static 309 | { 310 | return $this->withSubfolder(self::VIDEO, $path, false); 311 | } 312 | 313 | /** 314 | * @param string $path 315 | * @return static 316 | */ 317 | public function withFontsFolder(string $path = 'fonts'): static 318 | { 319 | return $this->withSubfolder(self::FONT, $path, false); 320 | } 321 | 322 | /** 323 | * @param string $name 324 | * @param string|null $path 325 | * @param bool $supportMinify 326 | * @return static 327 | */ 328 | public function withSubfolder( 329 | string $name, 330 | ?string $path = null, 331 | bool $supportMinify = false 332 | ): static { 333 | 334 | $name = trim($name, '/'); 335 | $this->subFolders[$name] = [ltrim(trailingslashit($path ?? $name), '/'), $supportMinify]; 336 | 337 | return $this; 338 | } 339 | 340 | /** 341 | * @param string $relativePath 342 | * @return string 343 | */ 344 | public function imgUrl(string $relativePath): string 345 | { 346 | return $this->assetUrl($relativePath, self::IMAGE); 347 | } 348 | 349 | /** 350 | * @param string $relativePath 351 | * @return string 352 | */ 353 | public function rawImgUrl(string $relativePath): string 354 | { 355 | return $this->rawAssetUrl($relativePath, self::IMAGE); 356 | } 357 | 358 | /** 359 | * @param string $relativePath 360 | * @return string 361 | */ 362 | public function cssUrl(string $relativePath): string 363 | { 364 | return $this->assetUrl($relativePath, self::CSS); 365 | } 366 | 367 | /** 368 | * @param string $relativePath 369 | * @return string 370 | */ 371 | public function rawCssUrl(string $relativePath): string 372 | { 373 | return $this->rawAssetUrl($relativePath, self::CSS); 374 | } 375 | 376 | /** 377 | * @param string $relativePath 378 | * @return string 379 | */ 380 | public function jsUrl(string $relativePath): string 381 | { 382 | return $this->assetUrl($relativePath, self::JS); 383 | } 384 | 385 | /** 386 | * @param string $relativePath 387 | * @return string 388 | */ 389 | public function rawJsUrl(string $relativePath): string 390 | { 391 | return $this->rawAssetUrl($relativePath, self::JS); 392 | } 393 | 394 | /** 395 | * @param string $relativePath 396 | * @return string 397 | */ 398 | public function videoUrl(string $relativePath): string 399 | { 400 | return $this->assetUrl($relativePath, self::VIDEO); 401 | } 402 | 403 | /** 404 | * @param string $relativePath 405 | * @return string 406 | */ 407 | public function rawVideoUrl(string $relativePath): string 408 | { 409 | return $this->rawAssetUrl($relativePath, self::VIDEO); 410 | } 411 | 412 | /** 413 | * @param string $relativePath 414 | * @return string 415 | */ 416 | public function fontsUrl(string $relativePath): string 417 | { 418 | return $this->assetUrl($relativePath, self::FONT); 419 | } 420 | 421 | /** 422 | * @param string $relativePath 423 | * @return string 424 | */ 425 | public function rawFontsUrl(string $relativePath): string 426 | { 427 | return $this->rawAssetUrl($relativePath, self::FONT); 428 | } 429 | 430 | /** 431 | * @param string $relativePath 432 | * @param string $folder 433 | * @return string 434 | */ 435 | public function assetUrl(string $relativePath, string $folder = ''): string 436 | { 437 | [$url] = $this->assetUrlForEnqueue($relativePath, $folder); 438 | 439 | return $url; 440 | } 441 | 442 | /** 443 | * @param string $filename 444 | * @param string $folder 445 | * @return string 446 | */ 447 | public function rawAssetUrl(string $filename, string $folder = ''): string 448 | { 449 | [$url] = $this->prepareRawUrlData($filename, $folder); 450 | 451 | return $url; 452 | } 453 | 454 | /** 455 | * @param string $name 456 | * @param array $deps 457 | * @param string $media 458 | * @return Enqueue\CssEnqueue 459 | */ 460 | public function registerStyle( 461 | string $name, 462 | array $deps = [], 463 | string $media = 'all' 464 | ): Enqueue\CssEnqueue { 465 | 466 | return $this->doEnqueueOrRegisterStyle('register', $name, null, $deps, $media); 467 | } 468 | 469 | /** 470 | * @param string $name 471 | * @param array $deps 472 | * @param string $media 473 | * @return Enqueue\CssEnqueue 474 | */ 475 | public function enqueueStyle( 476 | string $name, 477 | array $deps = [], 478 | string $media = 'all' 479 | ): Enqueue\CssEnqueue { 480 | 481 | return $this->doEnqueueOrRegisterStyle('enqueue', $name, null, $deps, $media); 482 | } 483 | 484 | /** 485 | * @param string $name 486 | * @param string $url 487 | * @param array $deps 488 | * @param string $media 489 | * @return Enqueue\CssEnqueue 490 | */ 491 | public function registerExternalStyle( 492 | string $name, 493 | string $url, 494 | array $deps = [], 495 | string $media = 'all' 496 | ): Enqueue\CssEnqueue { 497 | 498 | return $this->doEnqueueOrRegisterStyle('register', $name, $url, $deps, $media); 499 | } 500 | 501 | /** 502 | * @param string $name 503 | * @param string $url 504 | * @param array $deps 505 | * @param string $media 506 | * @return Enqueue\CssEnqueue 507 | */ 508 | public function enqueueExternalStyle( 509 | string $name, 510 | string $url, 511 | array $deps = [], 512 | string $media = 'all' 513 | ): Enqueue\CssEnqueue { 514 | 515 | return $this->doEnqueueOrRegisterStyle('enqueue', $name, $url, $deps, $media); 516 | } 517 | 518 | /** 519 | * @param string $name 520 | * @param array $deps 521 | * @param Enqueue\Strategy|bool|array|string|null $strategy 522 | * @return Enqueue\JsEnqueue 523 | */ 524 | public function registerScript( 525 | string $name, 526 | array $deps = [], 527 | Enqueue\Strategy|bool|array|string|null $strategy = null 528 | ): Enqueue\JsEnqueue { 529 | 530 | return $this->doEnqueueOrRegisterScript('register', $name, null, $deps, $strategy); 531 | } 532 | 533 | /** 534 | * @param string $name 535 | * @param array $deps 536 | * @param Enqueue\Strategy|bool|array|string|null $strategy 537 | * @return Enqueue\JsEnqueue 538 | */ 539 | public function enqueueScript( 540 | string $name, 541 | array $deps = [], 542 | Enqueue\Strategy|bool|array|string|null $strategy = null 543 | ): Enqueue\JsEnqueue { 544 | 545 | return $this->doEnqueueOrRegisterScript('enqueue', $name, null, $deps, $strategy); 546 | } 547 | 548 | /** 549 | * @param string $name 550 | * @param string $url 551 | * @param array $deps 552 | * @param Enqueue\Strategy|bool|array|string|null $strategy 553 | * @return Enqueue\JsEnqueue 554 | */ 555 | public function registerExternalScript( 556 | string $name, 557 | string $url, 558 | array $deps = [], 559 | Enqueue\Strategy|bool|array|string|null $strategy = null 560 | ): Enqueue\JsEnqueue { 561 | 562 | return $this->doEnqueueOrRegisterScript('register', $name, $url, $deps, $strategy); 563 | } 564 | 565 | /** 566 | * @param string $name 567 | * @param string $url 568 | * @param array $deps 569 | * @param Enqueue\Strategy|bool|array $strategy 570 | * @return Enqueue\JsEnqueue 571 | */ 572 | public function enqueueExternalScript( 573 | string $name, 574 | string $url, 575 | array $deps = [], 576 | Enqueue\Strategy|bool|array $strategy = true 577 | ): Enqueue\JsEnqueue { 578 | 579 | return $this->doEnqueueOrRegisterScript('enqueue', $name, $url, $deps, $strategy); 580 | } 581 | 582 | /** 583 | * @param string $name 584 | * @return static 585 | */ 586 | public function dequeueStyle(string $name): static 587 | { 588 | return $this->deregisterOrDequeue($name, self::CSS, deregister: false); 589 | } 590 | 591 | /** 592 | * @param string $name 593 | * @return static 594 | */ 595 | public function deregisterStyle(string $name): static 596 | { 597 | return $this->deregisterOrDequeue($name, self::CSS, deregister: true); 598 | } 599 | 600 | /** 601 | * @param string $name 602 | * @return static 603 | */ 604 | public function dequeueScript(string $name): static 605 | { 606 | return $this->deregisterOrDequeue($name, self::JS, deregister: false); 607 | } 608 | 609 | /** 610 | * @param string $name 611 | * @return static 612 | */ 613 | public function deregisterScript(string $name): static 614 | { 615 | return $this->deregisterOrDequeue($name, self::JS, deregister: true); 616 | } 617 | 618 | /** 619 | * @param string $name 620 | * @return string 621 | */ 622 | public function handleForName(string $name): string 623 | { 624 | if ($name === '') { 625 | return ''; 626 | } 627 | 628 | $replaced = preg_replace(['~\.[a-z0-9_-]+$~i', '~[^a-z0-9\-]+~i'], ['', '-'], $name); 629 | $prepared = is_string($replaced) ? trim($replaced, '-') : trim($name, '-'); 630 | 631 | return ($this->handlePrefix !== '') ? "{$this->handlePrefix}-{$prepared}" : $prepared; 632 | } 633 | 634 | /** 635 | * @param array $jsDeps 636 | * @param array $cssDeps 637 | * @return Enqueue\Collection 638 | */ 639 | public function registerAllFromManifest( 640 | array $jsDeps = [], 641 | array $cssDeps = [] 642 | ): Enqueue\Collection { 643 | 644 | $collection = []; 645 | $urls = $this->factory->manifestUrlResolver()->resolveAll(); 646 | foreach ($urls as $name => $url) { 647 | $registered = str_ends_with(strtolower($name), '.css') 648 | ? $this->registerStyle($name, $cssDeps) 649 | : $this->registerScript($name, $jsDeps); 650 | do_action('brain.assets.registered-from-manifest', $registered); 651 | $collection[] = $registered; 652 | } 653 | 654 | $registeredAll = Enqueue\Collection::new($this, ...$collection); 655 | do_action('brain.assets.registered-all-from-manifest', $registeredAll); 656 | 657 | return $registeredAll; 658 | } 659 | 660 | /** 661 | * @param 'register'|'enqueue' $type 662 | * @param string $name 663 | * @param string|null $url 664 | * @param array $deps 665 | * @param string $media 666 | * @return Enqueue\CssEnqueue 667 | */ 668 | private function doEnqueueOrRegisterStyle( 669 | string $type, 670 | string $name, 671 | ?string $url, 672 | array $deps, 673 | string $media 674 | ): Enqueue\CssEnqueue { 675 | 676 | $isEnqueue = $type === 'enqueue'; 677 | $handle = $this->handleForName($name); 678 | 679 | $existing = $this->maybeRegistered($handle, $isEnqueue, self::CSS); 680 | if ($existing instanceof Enqueue\CssEnqueue) { 681 | return $existing; 682 | } 683 | 684 | [$url, $useMinify] = ($url === null) 685 | ? $this->assetUrlForEnqueue($name, self::CSS) 686 | : [$this->adjustAbsoluteUrl($url), false]; 687 | 688 | $deps = $this->prepareDeps($deps, $url, $useMinify); 689 | 690 | /** @var callable $callback */ 691 | $callback = $isEnqueue ? 'wp_enqueue_style' : 'wp_register_style'; 692 | 693 | $callback($handle, $url, $deps, null, $media); 694 | 695 | $enqueued = $isEnqueue 696 | ? Enqueue\CssEnqueue::new($handle) 697 | : Enqueue\CssEnqueue::newRegistration($handle); 698 | $this->collection = $this->collection->append($enqueued); 699 | $this->setupRemoval(); 700 | 701 | return $enqueued; 702 | } 703 | 704 | /** 705 | * @param 'register'|'enqueue' $type 706 | * @param string $name 707 | * @param string|null $url 708 | * @param array $deps 709 | * @param Enqueue\Strategy|bool|array|string|null $strategy 710 | * @return Enqueue\JsEnqueue 711 | */ 712 | private function doEnqueueOrRegisterScript( 713 | string $type, 714 | string $name, 715 | ?string $url, 716 | array $deps, 717 | Enqueue\Strategy|bool|array|string|null $strategy 718 | ): Enqueue\JsEnqueue { 719 | 720 | $isEnqueue = $type === 'enqueue'; 721 | $handle = $this->handleForName($name); 722 | 723 | $existing = $this->maybeRegistered($handle, $isEnqueue, self::JS); 724 | if ($existing instanceof Enqueue\JsEnqueue) { 725 | return $existing; 726 | } 727 | 728 | [$url, $useMinify] = ($url === null) 729 | ? $this->assetUrlForEnqueue($name, self::JS) 730 | : [$this->adjustAbsoluteUrl($url), false]; 731 | 732 | $strategy = Enqueue\Strategy::new($strategy); 733 | $deps = $this->prepareDeps($deps, $url, $useMinify); 734 | 735 | /** @var callable $callback */ 736 | $callback = $isEnqueue ? 'wp_enqueue_script' : 'wp_register_script'; 737 | 738 | $callback($handle, $url, $deps, null, $strategy->toArray()); 739 | 740 | $enqueued = $isEnqueue 741 | ? Enqueue\JsEnqueue::new($handle, $strategy) 742 | : Enqueue\JsEnqueue::newRegistration($handle, $strategy); 743 | $this->collection = $this->collection->append($enqueued); 744 | $this->setupRemoval(); 745 | 746 | return $enqueued; 747 | } 748 | 749 | /** 750 | * @param string $name 751 | * @param 'css'|'js' $type 752 | * @param bool $deregister 753 | * @return static 754 | */ 755 | private function deregisterOrDequeue(string $name, string $type, bool $deregister): static 756 | { 757 | $handle = $this->handleForName($name); 758 | $existing = $this->maybeRegistered($handle, null, $type); 759 | if ($existing instanceof Enqueue\Enqueue) { 760 | $deregister ? $existing->deregister() : $existing->dequeue(); 761 | 762 | return $this; 763 | } 764 | 765 | match ($type) { 766 | 'css' => $deregister ? wp_deregister_style($handle) : wp_dequeue_script($handle), 767 | 'js' => $deregister ? wp_deregister_script($handle) : wp_dequeue_script($handle), 768 | }; 769 | 770 | return $this; 771 | } 772 | 773 | /** 774 | * @param string $filename 775 | * @param string $folder 776 | * @return list{string, array, bool} 777 | */ 778 | private function prepareRawUrlData(string $filename, string $folder = ''): array 779 | { 780 | $urlData = parse_url($filename); 781 | 782 | // Looks like an absolute URL 783 | if (isset($urlData['scheme']) || isset($urlData['host'])) { 784 | return [$this->rawUrlFromAbsolute($filename, $folder), $urlData, false]; 785 | } 786 | 787 | $path = $urlData['path'] ?? null; 788 | if (($path === null) || ($path === '') || (trim($path, '/') === '')) { 789 | return ['', $urlData, false]; 790 | } 791 | 792 | $relativeUrl = $this->buildRelativeUrl($folder, ltrim($path, '/'), $urlData['query'] ?? ''); 793 | 794 | $supportMinify = $this->subFolders[$folder][1] ?? false; 795 | $minifyResolver = ($supportMinify && !$this->context()->isDebug()) 796 | ? $this->minifyResolver 797 | : null; 798 | $url = $this->factory->urlResolver()->resolve($relativeUrl, $minifyResolver); 799 | 800 | return [$this->adjustAbsoluteUrl($url), $urlData, $minifyResolver !== null]; 801 | } 802 | 803 | /** 804 | * @param string $relativePath 805 | * @param string $folder 806 | * @return list{string, bool} 807 | */ 808 | private function assetUrlForEnqueue(string $relativePath, string $folder = ''): array 809 | { 810 | [$url, $urlData, $useMinify] = $this->prepareRawUrlData($relativePath, $folder); 811 | 812 | if (!$this->addVersion) { 813 | return [$url, false]; 814 | } 815 | 816 | $query = $urlData['query'] ?? null; 817 | if (($query !== '') && ($query !== false) && ($query !== null)) { 818 | return [$url, false]; 819 | } 820 | 821 | $version = $this->prepareVersion($url, $useMinify); 822 | 823 | if (($version !== null) && ($version !== '')) { 824 | $url = (string) add_query_arg(Version\Version::QUERY_VAR, $version, $url); 825 | } 826 | 827 | return [$url, $useMinify]; 828 | } 829 | 830 | /** 831 | * @param string $filename 832 | * @param string $folder 833 | * @return string 834 | */ 835 | private function rawUrlFromAbsolute(string $filename, string $folder): string 836 | { 837 | // Let's see if we can reduce it to a relative URL 838 | $maybeRelative = $this->tryRelativeUrl($filename, $folder); 839 | if ($maybeRelative === '') { 840 | // If not, we just return it 841 | return $this->adjustAbsoluteUrl($filename); 842 | } 843 | 844 | // If yes, we start over with relative URL 845 | return $this->rawAssetUrl($maybeRelative, $folder); 846 | } 847 | 848 | /** 849 | * @param string $folder 850 | * @param string $path 851 | * @param string $query 852 | * @return string 853 | */ 854 | private function buildRelativeUrl(string $folder, string $path, string $query): string 855 | { 856 | $relativeUrl = $folder 857 | ? ltrim(trailingslashit(($this->subFolders[$folder][0] ?? '')) . $path, '/') 858 | : $path; 859 | 860 | $ext = pathinfo($path, PATHINFO_EXTENSION); 861 | 862 | if (($ext === '') && in_array($folder, [self::CSS, self::JS], true)) { 863 | $ext = $folder === self::CSS ? 'css' : 'js'; 864 | $relativeUrl .= ".{$ext}"; 865 | } 866 | 867 | ($query !== '') and $relativeUrl .= "?{$query}"; 868 | 869 | return $relativeUrl; 870 | } 871 | 872 | /** 873 | * @param string $url 874 | * @return string 875 | */ 876 | private function adjustAbsoluteUrl(string $url): string 877 | { 878 | if (str_starts_with($url, '//')) { 879 | return $url; 880 | } 881 | 882 | $forceHttps = is_ssl() && $this->forceSecureUrls; 883 | $scheme = parse_url($url, PHP_URL_SCHEME); 884 | 885 | if ( 886 | ($scheme === '') 887 | || ($scheme === false) 888 | || ((strtolower($scheme) !== 'https') && $forceHttps) 889 | ) { 890 | return (string) set_url_scheme($url, $forceHttps ? 'https' : null); 891 | } 892 | 893 | return $url; 894 | } 895 | 896 | /** 897 | * @param string $url 898 | * @param string $folder 899 | * @param string|null $baseUrl 900 | * @return string 901 | * 902 | * phpcs:disable Generic.Metrics.CyclomaticComplexity 903 | */ 904 | private function tryRelativeUrl(string $url, string $folder, ?string $baseUrl = null): string 905 | { 906 | // phpcs:enable Generic.Metrics.CyclomaticComplexity 907 | $urlData = parse_url($url); 908 | $baseData = parse_url($baseUrl ?? $this->context()->baseUrl()); 909 | 910 | $urlHost = $urlData['host'] ?? ''; 911 | $urlPath = trim($urlData['path'] ?? '', '/'); 912 | $urlQuery = $urlData['query'] ?? ''; 913 | 914 | $basePath = trim($baseData['path'] ?? '', '/'); 915 | $baseHost = $baseData['host'] ?? ''; 916 | 917 | $subFolder = trim(($this->subFolders[$folder][0] ?? '')); 918 | $subFolder and $basePath .= "/{$subFolder}"; 919 | 920 | $urlComp = trailingslashit($urlHost) . $urlPath; 921 | $baseComp = trailingslashit($baseHost) . $basePath; 922 | 923 | if (str_starts_with($urlComp, $baseComp)) { 924 | $relative = trim(substr($urlComp, strlen($baseComp)), '/'); 925 | 926 | return $urlQuery ? "{$relative}?{$urlQuery}" : $relative; 927 | } 928 | 929 | if (($baseUrl === null) && $this->context()->hasAlternative()) { 930 | return $this->tryRelativeUrl($url, $folder, $this->context()->altBaseUrl()); 931 | } 932 | 933 | return ''; 934 | } 935 | 936 | /** 937 | * @param string $url 938 | * @param bool $useMinify 939 | * @return string|null 940 | */ 941 | private function prepareVersion(string $url, bool $useMinify): ?string 942 | { 943 | $factory = $this->factory; 944 | 945 | $versionStr = match (true) { 946 | $this->context()->isDebug() => str_replace(' ', '-', microtime()), 947 | $this->useDepExtractionData => $factory->dependencyInfoExtractor()->readVersion($url), 948 | default => null, 949 | }; 950 | 951 | $urlNoMin = (($versionStr === null) && $this->useDepExtractionData && $useMinify) 952 | ? $this->unminifiedUrl($url) 953 | : null; 954 | if ($urlNoMin !== null) { 955 | $versionStr = $factory->dependencyInfoExtractor()->readVersion($urlNoMin); 956 | } 957 | 958 | $version = $factory->version(); 959 | 960 | $versionStr ??= $version->calculate($url); 961 | if (($versionStr === null) && ($urlNoMin !== null)) { 962 | $versionStr = $version->calculate($urlNoMin); 963 | } 964 | 965 | return $versionStr; 966 | } 967 | 968 | /** 969 | * @param array $deps 970 | * @param string|null $url 971 | * @param bool $useMinify 972 | * @return list 973 | */ 974 | private function prepareDeps(array $deps, ?string $url, bool $useMinify): array 975 | { 976 | $prepared = []; 977 | 978 | foreach ($deps as $dep) { 979 | $name = null; 980 | if (is_string($dep)) { 981 | $name = $dep; 982 | } elseif ($dep instanceof Enqueue\Enqueue) { 983 | $name = $dep->handle(); 984 | } 985 | 986 | if (($name !== null) && ($name !== '')) { 987 | $prepared[] = $name; 988 | } 989 | } 990 | 991 | if (($url === null) || !$this->useDepExtractionData) { 992 | /** @var list */ 993 | return array_unique($prepared); 994 | } 995 | 996 | $infoExtractor = $this->factory->dependencyInfoExtractor(); 997 | $deps = $infoExtractor->readDependencies($url); 998 | if ($useMinify && ($deps === [])) { 999 | $urlNoMin = $this->unminifiedUrl($url); 1000 | if ($urlNoMin !== null) { 1001 | $deps = $infoExtractor->readDependencies($urlNoMin); 1002 | } 1003 | } 1004 | 1005 | foreach ($deps as $dep) { 1006 | if (is_string($dep) && ($dep !== '')) { 1007 | $prepared[] = $dep; 1008 | } 1009 | } 1010 | 1011 | /** @var list */ 1012 | return array_unique($prepared); 1013 | } 1014 | 1015 | /** 1016 | * @param string $url 1017 | * @return string|null 1018 | */ 1019 | private function unminifiedUrl(string $url): ?string 1020 | { 1021 | if (preg_match('~^(.+?)\.min\.(css|js)$~i', $url, $matches) === 1) { 1022 | return "{$matches[1]}.{$matches[2]}"; 1023 | } 1024 | 1025 | return null; 1026 | } 1027 | 1028 | /** 1029 | * @param string $handle 1030 | * @param bool|null $enqueue 1031 | * @param 'css'|'js' $type 1032 | * @return Enqueue\Enqueue|null 1033 | */ 1034 | private function maybeRegistered(string $handle, ?bool $enqueue, string $type): ?Enqueue\Enqueue 1035 | { 1036 | $existing = $this->collection()->oneByHandle($handle, $type); 1037 | if (($existing !== null) || ($enqueue === null)) { 1038 | if (($existing !== null) && ($enqueue === true) && !$existing->isEnqueued()) { 1039 | $existing->enqueue(); 1040 | } 1041 | 1042 | return $existing; 1043 | } 1044 | 1045 | if (($type === 'css') && wp_styles()->query($handle)) { 1046 | wp_dequeue_style($handle); 1047 | wp_deregister_style($handle); 1048 | } elseif (($type === 'js') && wp_scripts()->query($handle)) { 1049 | wp_dequeue_script($handle); 1050 | wp_deregister_script($handle); 1051 | } 1052 | 1053 | return null; 1054 | } 1055 | 1056 | /** 1057 | * @return void 1058 | */ 1059 | private function setupRemoval(): void 1060 | { 1061 | $this->removalConfigured or $this->removalConfigured = add_action( 1062 | 'brain.assets.deregistered', 1063 | function (Enqueue\Enqueue $enqueue): void { 1064 | $this->collection = $this->collection->remove($enqueue); 1065 | } 1066 | ); 1067 | } 1068 | } 1069 | -------------------------------------------------------------------------------- /src/Context/Context.php: -------------------------------------------------------------------------------- 1 | name; 185 | } 186 | 187 | /** 188 | * @param string $name 189 | * @return static 190 | */ 191 | public function withName(string $name): static 192 | { 193 | return new static( 194 | $name, 195 | $this->basePath, 196 | $this->baseUrl, 197 | $this->altBasePath, 198 | $this->altBaseUrl, 199 | $this->manifestPath, 200 | $this->isDebug 201 | ); 202 | } 203 | 204 | /** 205 | * @return bool 206 | */ 207 | public function isDebug(): bool 208 | { 209 | return $this->isDebug; 210 | } 211 | 212 | /** 213 | * @return static 214 | */ 215 | public function enableDebug(): static 216 | { 217 | $this->isDebug = true; 218 | 219 | return $this; 220 | } 221 | 222 | /** 223 | * @return static 224 | */ 225 | public function disableDebug(): static 226 | { 227 | $this->isDebug = false; 228 | 229 | return $this; 230 | } 231 | 232 | /** 233 | * @return bool 234 | */ 235 | public function isSecure(): bool 236 | { 237 | return (bool) is_ssl(); 238 | } 239 | 240 | /** 241 | * @return non-falsy-string 242 | */ 243 | public function basePath(): string 244 | { 245 | return $this->basePath; 246 | } 247 | 248 | /** 249 | * @return non-falsy-string 250 | */ 251 | public function manifestJsonPath(): string 252 | { 253 | return ($this->manifestPath ?? $this->basePath) . 'manifest.json'; 254 | } 255 | 256 | /** 257 | * @return non-falsy-string 258 | */ 259 | public function baseUrl(): string 260 | { 261 | return $this->baseUrl; 262 | } 263 | 264 | /** 265 | * @return non-falsy-string|null 266 | */ 267 | public function altBasePath(): ?string 268 | { 269 | return $this->altBasePath; 270 | } 271 | 272 | /** 273 | * @return non-falsy-string|null 274 | */ 275 | public function altBaseUrl(): ?string 276 | { 277 | return $this->altBaseUrl; 278 | } 279 | 280 | /** 281 | * @return bool 282 | */ 283 | public function hasAlternative(): bool 284 | { 285 | return $this->altBasePath && $this->altBaseUrl; 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/Enqueue/AbstractEnqueue.php: -------------------------------------------------------------------------------- 1 | isEnqueue; 28 | } 29 | 30 | /** 31 | * @return static 32 | */ 33 | final public function dequeue(): static 34 | { 35 | if ($this->isEnqueue) { 36 | $this->isCss() 37 | ? wp_dequeue_style($this->handle()) 38 | : wp_dequeue_script($this->handle()); 39 | $this->isEnqueue = false; 40 | } 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * @return static 47 | */ 48 | final public function enqueue(): static 49 | { 50 | if (!$this->isEnqueue) { 51 | $this->isCss() 52 | ? wp_enqueue_style($this->handle()) 53 | : wp_enqueue_script($this->handle()); 54 | $this->isEnqueue = true; 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @return void 62 | */ 63 | final public function deregister(): void 64 | { 65 | $this->dequeue(); 66 | $this->isCss() 67 | ? wp_deregister_style($this->handle()) 68 | : wp_deregister_script($this->handle()); 69 | do_action('brain.assets.deregistered', $this); 70 | } 71 | 72 | /** 73 | * @return bool 74 | */ 75 | final public function isJs(): bool 76 | { 77 | return !$this->isCss; 78 | } 79 | 80 | /** 81 | * @return bool 82 | */ 83 | final public function isCss(): bool 84 | { 85 | return $this->isCss; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Enqueue/Collection.php: -------------------------------------------------------------------------------- 1 | 11 | * @psalm-consistent-constructor 12 | * @psalm-pure 13 | */ 14 | class Collection implements \IteratorAggregate, \Countable 15 | { 16 | /** @var list */ 17 | private array $collection; 18 | 19 | /** 20 | * @param Assets $assets 21 | * @param Enqueue ...$enqueues 22 | * @return static 23 | * 24 | * @no-named-arguments 25 | */ 26 | public static function new(Assets $assets, Enqueue ...$enqueues): static 27 | { 28 | return new static($assets, ...$enqueues); 29 | } 30 | 31 | /** 32 | * @param Assets $assets 33 | * @param Enqueue ...$enqueues 34 | * 35 | * @no-named-arguments 36 | */ 37 | final protected function __construct( 38 | private Assets $assets, 39 | Enqueue ...$enqueues 40 | ) { 41 | 42 | $this->collection = $enqueues; 43 | } 44 | 45 | /** 46 | * @param Enqueue $enqueue 47 | * @return static 48 | */ 49 | public function append(Enqueue $enqueue): static 50 | { 51 | $collection = []; 52 | $enqueueId = $this->id($enqueue); 53 | foreach ($this->collection as $item) { 54 | if ($enqueueId === $this->id($item)) { 55 | return clone $this; 56 | } 57 | $collection[] = $item; 58 | } 59 | $collection[] = $enqueue; 60 | 61 | return new static($this->assets, ...$collection); 62 | } 63 | 64 | /** 65 | * @param Enqueue $enqueue 66 | * @return static 67 | */ 68 | public function remove(Enqueue $enqueue): static 69 | { 70 | $collection = []; 71 | $enqueueId = $this->id($enqueue); 72 | foreach ($this->collection as $item) { 73 | if ($this->id($item) !== $enqueueId) { 74 | $collection[] = $enqueue; 75 | } 76 | } 77 | 78 | return new static($this->assets, ...$collection); 79 | } 80 | 81 | /** 82 | * @param string $pattern 83 | * @param 'css'|'js'|null $type 84 | * @return static 85 | */ 86 | public function keep(string $pattern, ?string $type = null): static 87 | { 88 | $this->assertType($type, __METHOD__); 89 | if ($pattern === '') { 90 | return new static($this->assets); 91 | } 92 | 93 | return $this->filter($this->keepCallback($pattern, $type)); 94 | } 95 | 96 | /** 97 | * @param string $pattern 98 | * @param 'css'|'js'|null $type 99 | * @return static 100 | */ 101 | public function discard(string $pattern, ?string $type = null): static 102 | { 103 | $this->assertType($type, __METHOD__); 104 | if ($pattern === '') { 105 | return new static($this->assets, ...$this->collection); 106 | } 107 | 108 | return $this->filterOut($this->keepCallback($pattern, $type)); 109 | } 110 | 111 | /** 112 | * @param callable $callback 113 | * @return static 114 | */ 115 | public function filter(callable $callback): static 116 | { 117 | $collection = []; 118 | foreach ($this->collection as $enqueue) { 119 | try { 120 | $callback($enqueue) and $collection[] = $enqueue; 121 | } catch (\Throwable) { 122 | continue; 123 | } 124 | } 125 | 126 | return new static($this->assets, ...$collection); 127 | } 128 | 129 | /** 130 | * @param callable $callback 131 | * @return static 132 | */ 133 | public function filterOut(callable $callback): static 134 | { 135 | $collection = []; 136 | foreach ($this->collection as $enqueue) { 137 | try { 138 | $callback($enqueue) or $collection[] = $enqueue; 139 | } catch (\Throwable) { 140 | continue; 141 | } 142 | } 143 | 144 | return new static($this->assets, ...$collection); 145 | } 146 | 147 | /** 148 | * @param callable $callback 149 | * @return static 150 | */ 151 | public function apply(callable $callback): static 152 | { 153 | foreach ($this->collection as $enqueue) { 154 | try { 155 | $callback($enqueue); 156 | } catch (\Throwable) { 157 | continue; 158 | } 159 | } 160 | 161 | return new static($this->assets, ...$this->collection); 162 | } 163 | 164 | /** 165 | * @param callable $callback 166 | * @return static 167 | */ 168 | public function map(callable $callback): static 169 | { 170 | $collection = []; 171 | foreach ($this->collection as $enqueue) { 172 | try { 173 | $newEnqueue = $callback($enqueue); 174 | ($newEnqueue instanceof Enqueue) and $collection[] = $newEnqueue; 175 | } catch (\Throwable) { 176 | continue; 177 | } 178 | } 179 | 180 | return new static($this->assets, ...$collection); 181 | } 182 | 183 | /** 184 | * @param Collection $collection 185 | * @return $this 186 | */ 187 | public function merge(Collection $collection): static 188 | { 189 | $merged = []; 190 | foreach ($this->collection as $enqueue) { 191 | $merged[$this->id($enqueue)] = $enqueue; 192 | } 193 | 194 | foreach ($collection->collection as $enqueue) { 195 | $merged[$this->id($enqueue)] = $enqueue; 196 | } 197 | 198 | return static::new($this->assets, ...array_values($merged)); 199 | } 200 | 201 | /** 202 | * @param Collection $collection 203 | * @return $this 204 | */ 205 | public function diff(Collection $collection): static 206 | { 207 | $diff = []; 208 | foreach ($this->collection as $enqueue) { 209 | $diff[$this->id($enqueue)] = $enqueue; 210 | } 211 | 212 | foreach ($collection->collection as $enqueue) { 213 | unset($diff[$this->id($enqueue)]); 214 | } 215 | 216 | return static::new($this->assets, ...array_values($diff)); 217 | } 218 | 219 | /** 220 | * @return static 221 | */ 222 | public function cssOnly(): static 223 | { 224 | $collection = []; 225 | foreach ($this->collection as $enqueue) { 226 | $enqueue->isCss() and $collection[] = $enqueue; 227 | } 228 | 229 | return new static($this->assets, ...$collection); 230 | } 231 | 232 | /** 233 | * @return static 234 | */ 235 | public function jsOnly(): static 236 | { 237 | $collection = []; 238 | foreach ($this->collection as $enqueue) { 239 | $enqueue->isJs() and $collection[] = $enqueue; 240 | } 241 | 242 | return new static($this->assets, ...$collection); 243 | } 244 | 245 | /** 246 | * @return static 247 | */ 248 | public function enqueuedOnly(): static 249 | { 250 | $collection = []; 251 | foreach ($this->collection as $enqueue) { 252 | $enqueue->isEnqueued() and $collection[] = $enqueue; 253 | } 254 | 255 | return new static($this->assets, ...$collection); 256 | } 257 | 258 | /** 259 | * @return static 260 | */ 261 | public function notEnqueuedOnly(): static 262 | { 263 | $collection = []; 264 | foreach ($this->collection as $enqueue) { 265 | $enqueue->isEnqueued() or $collection[] = $enqueue; 266 | } 267 | 268 | return new static($this->assets, ...$collection); 269 | } 270 | 271 | /** 272 | * @param 'css'|'js'|null $type 273 | * @return Enqueue|null 274 | */ 275 | public function first(?string $type = null): ?Enqueue 276 | { 277 | $this->assertType($type, __METHOD__, 1); 278 | 279 | foreach ($this->collection as $enqueue) { 280 | if ($this->matchType($enqueue, $type)) { 281 | return $enqueue; 282 | } 283 | } 284 | 285 | return null; 286 | } 287 | 288 | /** 289 | * @param 'css'|'js'|null $type 290 | * @return Enqueue|null 291 | */ 292 | public function last(?string $type = null): ?Enqueue 293 | { 294 | $this->assertType($type, __METHOD__, 1); 295 | 296 | $collection = $this->collection; 297 | while ($collection) { 298 | $enqueue = array_pop($collection); 299 | if ($this->matchType($enqueue, $type)) { 300 | return $enqueue; 301 | } 302 | } 303 | 304 | return null; 305 | } 306 | 307 | /** 308 | * @param callable $callback 309 | * @return Enqueue|null 310 | * 311 | * phpcs:disable Inpsyde.CodeQuality.NestingLevel 312 | */ 313 | public function firstOf(callable $callback): ?Enqueue 314 | { 315 | // phpcs:enable Inpsyde.CodeQuality.NestingLevel 316 | foreach ($this->collection as $enqueue) { 317 | try { 318 | if ($callback($enqueue)) { 319 | return $enqueue; 320 | } 321 | } catch (\Throwable) { 322 | continue; 323 | } 324 | } 325 | 326 | return null; 327 | } 328 | 329 | /** 330 | * @param callable $callback 331 | * @return Enqueue|null 332 | * 333 | * phpcs:disable Inpsyde.CodeQuality.NestingLevel 334 | */ 335 | public function lastOf(callable $callback): ?Enqueue 336 | { 337 | // phpcs:enable Inpsyde.CodeQuality.NestingLevel 338 | $collection = $this->collection; 339 | while ($collection) { 340 | $enqueue = array_pop($collection); 341 | try { 342 | if ($callback($enqueue)) { 343 | return $enqueue; 344 | } 345 | } catch (\Throwable) { 346 | continue; 347 | } 348 | } 349 | 350 | return null; 351 | } 352 | 353 | /** 354 | * @param string $name 355 | * @param 'css'|'js'|null $type 356 | * @return ($type is 'css' ? CssEnqueue|null : ($type is 'js' ? JsEnqueue|null : Enqueue)) 357 | */ 358 | public function oneByName(string $name, ?string $type = null): ?Enqueue 359 | { 360 | $this->assertNonEmptyString($name, 'name'); 361 | $this->assertType($type, __METHOD__); 362 | 363 | $data = $this->collection ? $this->parseNameType($name, $type) : null; 364 | if ($data === null) { 365 | return null; 366 | } 367 | 368 | [$name, $noExtName, $type] = $data; 369 | foreach ($this->findNameCandidates($name, $noExtName, $type) as $candidate) { 370 | $handle = $this->assets->handleForName($candidate); 371 | $item = ($handle !== '') ? $this->findByHandle(strtolower($handle), $type) : null; 372 | if ($item !== null) { 373 | return $item; 374 | } 375 | } 376 | 377 | return null; 378 | } 379 | 380 | /** 381 | * @param string $handle 382 | * @param 'css'|'js'|null $type 383 | * @return ($type is 'css' ? CssEnqueue|null : ($type is 'js' ? JsEnqueue|null : Enqueue|null)) 384 | */ 385 | public function oneByHandle(string $handle, ?string $type = null): ?Enqueue 386 | { 387 | $this->assertNonEmptyString($handle, 'handle'); 388 | $this->assertType($type, __METHOD__); 389 | 390 | return $this->findByHandle(strtolower($handle), $type); 391 | } 392 | 393 | /** 394 | * @return \Iterator 395 | */ 396 | public function getIterator(): \Iterator 397 | { 398 | return new \ArrayIterator($this->collection); 399 | } 400 | 401 | /** 402 | * @return int 403 | */ 404 | public function count(): int 405 | { 406 | return count($this->collection); 407 | } 408 | 409 | /** 410 | * @return bool 411 | */ 412 | public function isEmpty(): bool 413 | { 414 | return $this->collection === []; 415 | } 416 | 417 | /** 418 | * @param 'css'|'js'|null $type 419 | * @return list 420 | */ 421 | public function handles(?string $type = null): array 422 | { 423 | $this->assertType($type, __METHOD__, 1); 424 | 425 | $handles = []; 426 | foreach ($this->collection as $enqueue) { 427 | if ($this->matchType($enqueue, $type)) { 428 | $handles[] = $enqueue->handle(); 429 | } 430 | } 431 | 432 | return $handles; 433 | } 434 | 435 | /** 436 | * @return void 437 | */ 438 | public function enqueue(): void 439 | { 440 | foreach ($this->collection as $enqueue) { 441 | $enqueue->enqueue(); 442 | } 443 | } 444 | 445 | /** 446 | * @return void 447 | */ 448 | public function dequeue(): void 449 | { 450 | foreach ($this->collection as $enqueue) { 451 | $enqueue->dequeue(); 452 | } 453 | } 454 | 455 | /** 456 | * @param non-empty-string $name 457 | * @param 'css'|'js'|null $type 458 | * @return null|list{ 459 | * non-empty-lowercase-string, 460 | * non-empty-lowercase-string|null, 461 | * 'css'|'js'|null 462 | * } 463 | */ 464 | private function parseNameType(string $name, ?string $type): ?array 465 | { 466 | $name = strtolower($name); 467 | $ext = $type; 468 | $noExtName = null; 469 | if (preg_match('~(.+?)\.(css|js)$~', $name, $matches)) { 470 | /** @var non-empty-lowercase-string $noExtName */ 471 | $noExtName = $matches[1]; 472 | /** @var 'css'|'js' $ext */ 473 | $ext = $matches[2]; 474 | } 475 | if (($type !== null) && ($ext !== $type)) { 476 | return null; 477 | } 478 | 479 | return [$name, $noExtName, $ext]; 480 | } 481 | 482 | /** 483 | * @param non-empty-lowercase-string $name 484 | * @param non-empty-lowercase-string|null $noExtName 485 | * @param 'css'|'js'|null $type 486 | * @return list 487 | */ 488 | private function findNameCandidates(string $name, ?string $noExtName, ?string $type): array 489 | { 490 | /** @var non-empty-lowercase-string|null $fullName */ 491 | $fullName = ($type !== null) && ($noExtName !== null) 492 | ? "{$noExtName}.{$type}" 493 | : null; 494 | 495 | $candidates = []; 496 | ($fullName !== null) and $candidates[] = $fullName; 497 | ($name !== $fullName) and $candidates[] = $name; 498 | (($noExtName !== null) && ($noExtName !== $name)) and $candidates[] = $noExtName; 499 | 500 | return $candidates; 501 | } 502 | 503 | /** 504 | * @param non-empty-lowercase-string $handle 505 | * @param 'css'|'js'|null $type 506 | * @return Enqueue|null 507 | */ 508 | private function findByHandle(string $handle, ?string $type): ?Enqueue 509 | { 510 | if (!$this->collection) { 511 | return null; 512 | } 513 | 514 | $prefix = strtolower($this->assets->handlePrefix()); 515 | $prefixedHandle = str_starts_with($handle, $prefix) ? null : "{$prefix}-{$handle}"; 516 | foreach ($this->collection as $enqueue) { 517 | if (!$this->matchType($enqueue, $type)) { 518 | continue; 519 | } 520 | 521 | /** @var non-empty-lowercase-string $itemHandle */ 522 | $itemHandle = strtolower($enqueue->handle()); 523 | if (($itemHandle === $handle) || ($itemHandle === $prefixedHandle)) { 524 | return $enqueue; 525 | } 526 | } 527 | 528 | return null; 529 | } 530 | 531 | /** 532 | * @param Enqueue $enqueue 533 | * @param 'css'|'js'|null $type 534 | * @return bool 535 | */ 536 | private function matchType(Enqueue $enqueue, ?string $type): bool 537 | { 538 | return ($type === null) || (($type === 'css') === $enqueue->isCss()); 539 | } 540 | 541 | /** 542 | * @param non-empty-string $pattern 543 | * @param 'css'|'js'|null $type 544 | * @return callable 545 | */ 546 | private function keepCallback(string $pattern, ?string $type): callable 547 | { 548 | $isRegex = $this->isRegex($pattern); 549 | $isGlob = !$isRegex && str_contains($pattern, '*'); 550 | $isRegex or $pattern = strtolower($pattern); 551 | 552 | return function (Enqueue $item) use ($pattern, $type, $isRegex, $isGlob): bool { 553 | if (!$this->matchType($item, $type)) { 554 | return false; 555 | } 556 | $handle = $item->handle(); 557 | 558 | return match (true) { 559 | $isGlob => fnmatch($pattern, strtolower($handle)), 560 | $isRegex => preg_match($pattern, $handle) === 1, 561 | default => str_contains(strtolower($handle), $pattern), 562 | }; 563 | }; 564 | } 565 | 566 | /** 567 | * @param non-empty-string $pattern 568 | * @return bool 569 | */ 570 | private function isRegex(string $pattern): bool 571 | { 572 | if ((strlen($pattern) < 3) || (trim($pattern) !== $pattern)) { 573 | return false; 574 | } 575 | 576 | $first = $pattern[0]; 577 | $last = substr($pattern, -1); 578 | if (($first === '{') && ($last === '}')) { 579 | return true; 580 | } 581 | 582 | // Formally, `-` is a valid regex delimiter, but we don't allow it as it is an allowed 583 | // character in handles. It means that a string like "-foo-" could be seen as a regex 584 | // instead of a basic string search. But `-` is not a commonly used delimiter, so it should 585 | // not be a big deal. 586 | if (($first === '\\') || ($first === '-') || preg_match('~[\w-]~i', $first) === 1) { 587 | return false; 588 | } 589 | 590 | if ($first === $last) { 591 | return true; 592 | } 593 | 594 | $sep1 = preg_quote($first, '{'); 595 | $sep2 = ($first === '{') ? preg_quote('}', '{') : $sep1; 596 | 597 | if (preg_match("{^{$sep1}.+?{$sep2}([inmsuxADJSXU]+)$}", $pattern, $matches) !== 1) { 598 | return false; 599 | } 600 | 601 | $flags = str_split($matches[1]); 602 | 603 | return array_unique($flags) === $flags; 604 | } 605 | 606 | /** 607 | * @param Enqueue $enqueue 608 | * @return string 609 | */ 610 | private function id(Enqueue $enqueue): string 611 | { 612 | return $enqueue->handle() . ($enqueue->isJs() ? '--js' : '--css'); 613 | } 614 | 615 | /** 616 | * @param string $str 617 | * @param 'handle'|'name' $varName 618 | * @return void 619 | * 620 | * @psalm-assert non-empty-string $str 621 | */ 622 | private function assertNonEmptyString(string $str, string $varName): void 623 | { 624 | if ($str === '') { 625 | throw new \TypeError( 626 | sprintf( 627 | '%s::%s() Argument 1 ($%s) bust be of type "non-empty-string", "" provided', 628 | __CLASS__, 629 | 'oneBy' . ucfirst($varName), // phpcs:disable 630 | $varName 631 | ) 632 | ); 633 | } 634 | } 635 | 636 | /** 637 | * @param string|null $type 638 | * @param string $method 639 | * @param int $num 640 | * @return void 641 | * 642 | * @psalm-assert 'css'|'js'|null $type 643 | */ 644 | private function assertType(?string $type, string $method, int $num = 2): void 645 | { 646 | if (($type !== null) && ($type !== Assets::CSS) && ($type !== Assets::JS)) { 647 | throw new \TypeError( 648 | sprintf( 649 | '%s() Argument %d ($type) bust be of type "null|css|js" %s provided', 650 | $method, // phpcs:ignore 651 | $num, 652 | esc_html($type) 653 | ) 654 | ); 655 | } 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/Enqueue/CssEnqueue.php: -------------------------------------------------------------------------------- 1 | isEnqueue = $isEnqueue; 49 | $this->isCss = true; 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function handle(): string 56 | { 57 | return $this->handle; 58 | } 59 | 60 | /** 61 | * @param string $condition 62 | * @return static 63 | */ 64 | public function withCondition(string $condition): static 65 | { 66 | wp_styles()->add_data($this->handle, 'conditional', $condition); 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * @return static 73 | */ 74 | public function asAlternate(): static 75 | { 76 | wp_styles()->add_data($this->handle, 'alt', true); 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * @param string $title 83 | * @return static 84 | */ 85 | public function withTitle(string $title): static 86 | { 87 | wp_styles()->add_data($this->handle, 'title', $title); 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * @param string $cssCode 94 | * @return static 95 | */ 96 | public function appendInline(string $cssCode): static 97 | { 98 | wp_add_inline_style($this->handle, $cssCode); 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * @param string $name 105 | * @param string|null $value 106 | * @return static 107 | */ 108 | public function useAttribute(string $name, ?string $value): static 109 | { 110 | $normalizedName = trim(strtolower($name)); 111 | 112 | if ($normalizedName === 'title') { 113 | return (($value !== null) && ($value !== '')) ? $this->withTitle($value) : $this; 114 | } 115 | 116 | if ($normalizedName === 'alternate') { 117 | return ($value === null) ? $this->asAlternate() : $this; 118 | } 119 | 120 | $this->setupFilterHooks(); 121 | $this->filters->addAttribute($name, $value); 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * @param callable $callback 128 | * @return static 129 | */ 130 | public function addFilter(callable $callback): static 131 | { 132 | $this->setupFilterHooks(); 133 | $this->filters->add($callback); 134 | 135 | return $this; 136 | } 137 | 138 | /** 139 | * @return void 140 | * 141 | * @psalm-assert Filters $this->filters 142 | */ 143 | private function setupFilterHooks(): void 144 | { 145 | if ($this->filters) { 146 | return; 147 | } 148 | 149 | $this->filters = Filters::newForStyles(); 150 | 151 | add_filter( 152 | 'style_loader_tag', 153 | function (mixed $tag, string $handle): mixed { 154 | if ( 155 | $this->filters 156 | && ($tag !== '') 157 | && ($handle === $this->handle) 158 | && is_string($tag) 159 | ) { 160 | return $this->filters->apply($tag); 161 | } 162 | 163 | return $tag; 164 | }, 165 | 10, 166 | 2 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Enqueue/Enqueue.php: -------------------------------------------------------------------------------- 1 | */ 20 | private array $filters = []; 21 | 22 | /** @var array */ 23 | private array $attributes = []; 24 | 25 | /** 26 | * @return static 27 | */ 28 | public static function newForScripts(): static 29 | { 30 | return new static('script'); 31 | } 32 | 33 | /** 34 | * @return static 35 | */ 36 | public static function newForStyles(): static 37 | { 38 | return new static('link'); 39 | } 40 | 41 | /** 42 | * @param 'link'|'script' $tag 43 | */ 44 | final protected function __construct( 45 | private string $tag 46 | ) { 47 | } 48 | 49 | /** 50 | * @param callable $filter 51 | * @return static 52 | */ 53 | public function add(callable $filter): static 54 | { 55 | $this->filters[] = $filter; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @param string $name 62 | * @param string|null $value 63 | * @return static 64 | */ 65 | public function addAttribute(string $name, ?string $value): static 66 | { 67 | if (array_key_exists($name, $this->attributes)) { 68 | return $this; 69 | } 70 | 71 | $name = ($name !== '') ? (string) sanitize_key($name) : ''; 72 | if ($name === '') { 73 | return $this; 74 | } 75 | 76 | if (($value !== null) && ($value !== '')) { 77 | $value = trim(esc_attr($value)); 78 | } 79 | 80 | $this->attributes[$name] = $value; 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * @param string $tag 87 | * @return string 88 | * 89 | * phpcs:disable Inpsyde.CodeQuality.NestingLevel 90 | */ 91 | public function apply(string $tag): string 92 | { 93 | // phpcs:enable Inpsyde.CodeQuality.NestingLevel 94 | 95 | foreach ($this->attributes as $name => $value) { 96 | if (preg_match('~\s+' . preg_quote($name, '~') . '(?:\s|=|>)~', $tag)) { 97 | continue; 98 | } 99 | 100 | $replace = ($value === null) ? $name : "{$name}=\"{$value}\""; 101 | $tag = str_replace("<{$this->tag}", "<{$this->tag} {$replace}", $tag); 102 | } 103 | 104 | foreach ($this->filters as $filter) { 105 | try { 106 | $maybeTag = $filter($tag); 107 | if (($maybeTag !== '') && is_string($maybeTag)) { 108 | $tag = $maybeTag; 109 | } 110 | } catch (\Throwable) { 111 | continue; 112 | } 113 | } 114 | 115 | return $tag; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Enqueue/JsEnqueue.php: -------------------------------------------------------------------------------- 1 | isCss = false; 53 | $this->isEnqueue = $isEnqueue; 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | public function handle(): string 60 | { 61 | return $this->handle; 62 | } 63 | 64 | /** 65 | * @param string $condition 66 | * @return static 67 | */ 68 | public function withCondition(string $condition): static 69 | { 70 | wp_scripts()->add_data($this->handle, 'conditional', $condition); 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * @param string $jsCode 77 | * @return static 78 | */ 79 | public function prependInline(string $jsCode): static 80 | { 81 | wp_add_inline_script($this->handle, $jsCode, 'before'); 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @param string $jsCode 88 | * @return static 89 | */ 90 | public function appendInline(string $jsCode): static 91 | { 92 | wp_add_inline_script($this->handle, $jsCode, 'after'); 93 | $this->removeStrategy(); 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @param string $objectName 100 | * @param array $data 101 | * @return static 102 | */ 103 | public function localize(string $objectName, array $data): static 104 | { 105 | wp_localize_script($this->handle, $objectName, $data); 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @param Strategy $strategy 112 | * @return static 113 | */ 114 | public function useStrategy(Strategy $strategy): static 115 | { 116 | $this->strategy = $strategy; 117 | $strategyName = match (true) { 118 | $strategy->isDefer() => Strategy::DEFER, 119 | $strategy->isAsync() => Strategy::ASYNC, 120 | default => false, 121 | }; 122 | 123 | wp_scripts()->add_data($this->handle, 'strategy', $strategyName); 124 | wp_scripts()->add_data($this->handle, 'group', $strategy->inFooter() ? 1 : false); 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * @return static 131 | */ 132 | public function useAsync(): static 133 | { 134 | return $this->useStrategy(Strategy::newAsync($this->strategy?->inFooter() ?? false)); 135 | } 136 | 137 | /** 138 | * @return static 139 | */ 140 | public function useDefer(): static 141 | { 142 | return $this->useStrategy(Strategy::newDefer($this->strategy?->inFooter() ?? false)); 143 | } 144 | 145 | /** 146 | * @param string $name 147 | * @param string $value 148 | * @return static 149 | */ 150 | public function useAttribute(string $name, ?string $value): static 151 | { 152 | $nameLower = strtolower($name); 153 | if (($nameLower !== 'async') && ($nameLower !== 'defer')) { 154 | $this->setupFilters()->addAttribute($name, $value); 155 | 156 | return $this; 157 | } 158 | 159 | if (strtolower($value ?? '') === 'false') { 160 | $this->removeStrategy(); 161 | 162 | return $this; 163 | } 164 | 165 | return ($nameLower === 'async') ? $this->useAsync() : $this->useDefer(); 166 | } 167 | 168 | /** 169 | * @param callable $callback 170 | * @return static 171 | */ 172 | public function addFilter(callable $callback): static 173 | { 174 | $this->setupFilters()->add($callback); 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * @return void 181 | */ 182 | private function removeStrategy(): void 183 | { 184 | if ($this->strategy) { 185 | $this->strategy = $this->strategy->removeStrategy(); 186 | wp_scripts()->add_data($this->handle, 'strategy', false); 187 | } 188 | } 189 | 190 | /** 191 | * @return Filters 192 | */ 193 | private function setupFilters(): Filters 194 | { 195 | if (!$this->filters) { 196 | $this->filters = Filters::newForScripts(); 197 | $this->filterScriptLoaderSrc(); 198 | } 199 | 200 | return $this->filters; 201 | } 202 | 203 | /** 204 | * @return void 205 | */ 206 | private function filterScriptLoaderSrc(): void 207 | { 208 | add_filter( 209 | 'script_loader_src', 210 | function (mixed $src, string $handle): mixed { 211 | if (($handle !== $this->handle) || !is_string($src) || ($src === '')) { 212 | return $src; 213 | } 214 | 215 | return $this->filterScriptLoaderTag($src); 216 | }, 217 | 10, 218 | 2 219 | ); 220 | } 221 | 222 | /** 223 | * @param non-empty-string $src 224 | * @return non-empty-string 225 | */ 226 | private function filterScriptLoaderTag(string $src): string 227 | { 228 | add_filter( 229 | 'script_loader_tag', 230 | function (mixed $tag, string $handle) use ($src): mixed { 231 | if (($handle === $this->handle) && is_string($tag) && ($tag !== '')) { 232 | return $this->filterTag($tag, $src); 233 | } 234 | 235 | return $tag; 236 | }, 237 | 10, 238 | 2 239 | ); 240 | 241 | return $src; 242 | } 243 | 244 | /** 245 | * @param non-empty-string $tag 246 | * @param non-empty-string $src 247 | * @return string 248 | */ 249 | private function filterTag(string $tag, string $src): string 250 | { 251 | $regex = '(?P.+)?' 252 | . '(?P)' 253 | . '(?P.+)?'; 254 | 255 | if (!preg_match("~{$regex}~is", $tag, $matches)) { 256 | return $tag; 257 | } 258 | 259 | $tagMatch = $matches['tag'] ?? ''; 260 | $tag = $this->filters ? $this->filters->apply($tagMatch) : $tagMatch; 261 | $before = $matches['before'] ?? ''; 262 | $after = $matches['after'] ?? ''; 263 | 264 | return $before . $tag . $after; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/Enqueue/Strategy.php: -------------------------------------------------------------------------------- 1 | 72 | * 73 | * @psalm-pure 74 | */ 75 | public static function newAsync(bool $inFooter = false): Strategy 76 | { 77 | return new self($inFooter, self::ASYNC); 78 | } 79 | 80 | /** 81 | * @template iF of bool 82 | * @param iF $inFooter 83 | * @return Strategy 84 | * 85 | * @psalm-pure 86 | */ 87 | public static function newDefer(bool $inFooter = false): Strategy 88 | { 89 | return new self($inFooter, self::DEFER); 90 | } 91 | 92 | /** 93 | * @return Strategy 94 | * 95 | * @psalm-pure 96 | */ 97 | public static function newAsyncInFooter(): Strategy 98 | { 99 | return new self(true, self::ASYNC); 100 | } 101 | 102 | /** 103 | * @return Strategy 104 | * 105 | * @psalm-pure 106 | */ 107 | public static function newDeferInFooter(): Strategy 108 | { 109 | return new self(true, self::DEFER); 110 | } 111 | 112 | /** 113 | * @return Strategy 114 | * 115 | * @psalm-pure 116 | */ 117 | public static function newInFooter(): Strategy 118 | { 119 | return new self(true, null); 120 | } 121 | 122 | /** 123 | * @return Strategy 124 | * 125 | * @psalm-pure 126 | */ 127 | public static function newInHead(): Strategy 128 | { 129 | return new self(false, null); 130 | } 131 | 132 | /** 133 | * @param F $inFooter 134 | * @param S $strategy 135 | */ 136 | private function __construct( 137 | private bool $inFooter, 138 | private string|null $strategy, 139 | ) { 140 | } 141 | 142 | /** 143 | * @return bool 144 | * 145 | * @psalm-assert-if-true true $this->inFooter 146 | * @psalm-assert-if-false false $this->inFooter 147 | */ 148 | public function inFooter(): bool 149 | { 150 | return $this->inFooter; 151 | } 152 | 153 | /** 154 | * @return bool 155 | * 156 | * @psalm-assert-if-true 'async'|'defer' $this->strategy 157 | * @psalm-assert-if-false null $this->strategy 158 | */ 159 | public function hasStrategy(): bool 160 | { 161 | return $this->strategy !== null; 162 | } 163 | 164 | /** 165 | * @return bool 166 | * 167 | * @psalm-assert-if-true 'async' $this->strategy 168 | * @psalm-assert-if-false 'defer'|null $this->strategy 169 | */ 170 | public function isAsync(): bool 171 | { 172 | return $this->strategy === self::ASYNC; 173 | } 174 | 175 | /** 176 | * @return bool 177 | * 178 | * @psalm-assert-if-true 'defer' $this->strategy 179 | * @psalm-assert-if-false 'async'|null $this->strategy 180 | */ 181 | public function isDefer(): bool 182 | { 183 | return $this->strategy === self::DEFER; 184 | } 185 | 186 | /** 187 | * @return static 188 | */ 189 | public function removeStrategy(): static 190 | { 191 | return $this->inFooter ? static::newInFooter() : static::newInHead(); 192 | } 193 | 194 | /** 195 | * @param Strategy $strategy 196 | * @return bool 197 | */ 198 | public function equals(Strategy $strategy): bool 199 | { 200 | return $strategy->toArray() === $this->toArray(); 201 | } 202 | 203 | /** 204 | * @return array{in_footer: bool, strategy?: 'async'|'defer'} 205 | */ 206 | public function toArray(): array 207 | { 208 | $data = ['in_footer' => $this->inFooter]; 209 | if ($this->strategy !== null) { 210 | $data['strategy'] = $this->strategy; 211 | } 212 | 213 | return $data; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | */ 11 | private array $objects = []; 12 | /** @var array */ 13 | private mixed $factories = []; 14 | 15 | /** 16 | * @param string $mainPluginFilePath 17 | * @param string $assetsDir 18 | * @return Context\Context 19 | */ 20 | public static function factoryContextForPlugin( 21 | string $mainPluginFilePath, 22 | string $assetsDir = '/' 23 | ): Context\Context { 24 | 25 | $name = (string) plugin_basename($mainPluginFilePath); 26 | if (substr_count($name, '/') > 0) { 27 | $name = explode('/', $name)[0]; 28 | } 29 | 30 | $assetsDir = '/' . trailingslashit(ltrim($assetsDir, '/')); 31 | $baseDir = dirname($mainPluginFilePath) . $assetsDir; 32 | /** @var non-falsy-string $baseUrl */ 33 | $baseUrl = (string) plugins_url($assetsDir, $mainPluginFilePath); 34 | 35 | return Context\WpContext::new($name, $baseDir, $baseUrl, null, null); 36 | } 37 | 38 | /** 39 | * @param string $assetsDir 40 | * @return Context\Context 41 | */ 42 | public static function factoryContextForTheme(string $assetsDir = '/'): Context\Context 43 | { 44 | $name = (string) get_template(); 45 | $dir = trailingslashit(ltrim($assetsDir, '/')); 46 | /** @var non-falsy-string $baseDir */ 47 | $baseDir = trailingslashit((string) get_template_directory()) . $dir; 48 | /** @var non-falsy-string $baseUrl */ 49 | $baseUrl = trailingslashit((string) get_template_directory_uri()) . $dir; 50 | 51 | return Context\WpContext::new($name, $baseDir, $baseUrl, null, null); 52 | } 53 | 54 | /** 55 | * @param string $assetsDir 56 | * @param string|null $parentAssetsDir 57 | * @return Context\Context 58 | */ 59 | public static function factoryContextForChildTheme( 60 | string $assetsDir = '/', 61 | ?string $parentAssetsDir = null 62 | ): Context\Context { 63 | 64 | $name = (string) get_stylesheet(); 65 | $dir = ltrim($assetsDir, '/'); 66 | /** @var non-falsy-string $baseDir */ 67 | $baseDir = trailingslashit((string) get_stylesheet_directory()) . $dir; 68 | /** @var non-falsy-string $baseUrl */ 69 | $baseUrl = trailingslashit((string) get_stylesheet_directory_uri()) . $dir; 70 | 71 | $altBaseDir = null; 72 | $altBaseUrl = null; 73 | if ($name !== (string) get_template()) { 74 | $parentDir = ltrim($parentAssetsDir ?? $assetsDir, '/'); 75 | /** @var non-falsy-string $altBaseDir */ 76 | $altBaseDir = trailingslashit((string) get_template_directory()) . $parentDir; 77 | /** @var non-falsy-string $altBaseUrl */ 78 | $altBaseUrl = trailingslashit((string) get_template_directory_uri()) . $parentDir; 79 | } 80 | 81 | return Context\WpContext::new($name, $baseDir, $baseUrl, $altBaseDir, $altBaseUrl); 82 | } 83 | 84 | /** 85 | * @param non-empty-string $name 86 | * @param non-falsy-string $baseDir 87 | * @param non-falsy-string $baseUrl 88 | * @return Context\Context 89 | */ 90 | public static function factoryContextForLibrary( 91 | string $name, 92 | string $baseDir, 93 | string $baseUrl 94 | ): Context\Context { 95 | 96 | return Context\WpContext::new($name, $baseDir, $baseUrl, null, null); 97 | } 98 | 99 | /** 100 | * @param non-empty-string $name 101 | * @param non-falsy-string $manifestJsonPath 102 | * @param non-falsy-string $baseUrl 103 | * @param non-falsy-string|null $basePath 104 | * @param non-falsy-string|null $altBasePath 105 | * @param non-falsy-string|null $altBaseUrl 106 | * @return Context\Context 107 | */ 108 | public static function factoryContextForManifest( 109 | string $name, 110 | string $manifestJsonPath, 111 | string $baseUrl, 112 | ?string $basePath = null, 113 | ?string $altBasePath = null, 114 | ?string $altBaseUrl = null 115 | ): Context\Context { 116 | 117 | $isDir = is_dir($manifestJsonPath); 118 | /** @var non-falsy-string $manifestPath */ 119 | $manifestPath = $isDir ? untrailingslashit($manifestJsonPath) : dirname($manifestJsonPath); 120 | $basePath ??= $manifestPath; 121 | 122 | return Context\WpContext::new( 123 | $name, 124 | $basePath, 125 | $baseUrl, 126 | $altBasePath, 127 | $altBaseUrl, 128 | $manifestPath 129 | ); 130 | } 131 | 132 | /** 133 | * @param Context\Context $context 134 | * @return static 135 | */ 136 | public static function new(Context\Context $context): static 137 | { 138 | return new static($context); 139 | } 140 | 141 | /** 142 | * @param Context\Context $context 143 | */ 144 | final protected function __construct(private Context\Context $context) 145 | { 146 | } 147 | 148 | /** 149 | * @template T of object 150 | * 151 | * @param class-string $class 152 | * @param callable(Factory):T $callback 153 | * @return static 154 | */ 155 | public function registerFactory(string $class, callable $callback): static 156 | { 157 | $this->factories[$class] = $callback; 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * @return Context\Context 164 | */ 165 | public function context(): Context\Context 166 | { 167 | return $this->context; 168 | } 169 | 170 | /** 171 | * @return Utils\PathFinder 172 | */ 173 | public function pathFinder(): Utils\PathFinder 174 | { 175 | return $this->factoryObject( 176 | Utils\PathFinder::class, 177 | static function (Factory $factory): Utils\PathFinder { 178 | return Utils\PathFinder::new($factory->context()); 179 | } 180 | ); 181 | } 182 | 183 | /** 184 | * @return Version\Version 185 | */ 186 | public function version(): Version\Version 187 | { 188 | return $this->factoryObject( 189 | Version\Version::class, 190 | static function (Factory $factory): Version\Version { 191 | return Version\LastModifiedVersion::new($factory->pathFinder()); 192 | } 193 | ); 194 | } 195 | 196 | /** 197 | * @return Version\LastModifiedVersion 198 | */ 199 | public function lastModifiedVersion(): Version\LastModifiedVersion 200 | { 201 | return $this->factoryObject( 202 | Version\LastModifiedVersion::class, 203 | static function (Factory $factory): Version\LastModifiedVersion { 204 | return Version\LastModifiedVersion::new($factory->pathFinder()); 205 | } 206 | ); 207 | } 208 | 209 | /** 210 | * @return Utils\DependencyInfoExtractor 211 | */ 212 | public function dependencyInfoExtractor(): Utils\DependencyInfoExtractor 213 | { 214 | return $this->factoryObject( 215 | Utils\DependencyInfoExtractor::class, 216 | static function (Factory $factory): Utils\DependencyInfoExtractor { 217 | return Utils\DependencyInfoExtractor::new($factory->pathFinder()); 218 | } 219 | ); 220 | } 221 | 222 | /** 223 | * @return UrlResolver\UrlResolver 224 | */ 225 | public function urlResolver(): UrlResolver\UrlResolver 226 | { 227 | return $this->factoryObject( 228 | UrlResolver\UrlResolver::class, 229 | static function (Factory $factory): UrlResolver\UrlResolver { 230 | return $factory->hasManifest() 231 | ? $factory->manifestUrlResolver() 232 | : $factory->directUrlResolver(); 233 | } 234 | ); 235 | } 236 | 237 | /** 238 | * @return UrlResolver\DirectUrlResolver 239 | */ 240 | public function directUrlResolver(): UrlResolver\DirectUrlResolver 241 | { 242 | return $this->factoryObject( 243 | UrlResolver\DirectUrlResolver::class, 244 | static function (Factory $factory): UrlResolver\DirectUrlResolver { 245 | return UrlResolver\DirectUrlResolver::new($factory->context()); 246 | } 247 | ); 248 | } 249 | 250 | /** 251 | * @return UrlResolver\ManifestUrlResolver 252 | */ 253 | public function manifestUrlResolver(): UrlResolver\ManifestUrlResolver 254 | { 255 | return $this->factoryObject( 256 | UrlResolver\ManifestUrlResolver::class, 257 | static function (Factory $factory): UrlResolver\ManifestUrlResolver { 258 | return UrlResolver\ManifestUrlResolver::new( 259 | $factory->directUrlResolver(), 260 | $factory->context()->manifestJsonPath() 261 | ); 262 | } 263 | ); 264 | } 265 | 266 | /** 267 | * @return UrlResolver\MinifyResolver 268 | */ 269 | public function minifyResolver(): UrlResolver\MinifyResolver 270 | { 271 | return $this->factoryObject( 272 | UrlResolver\MinifyResolver::class, 273 | static function (): UrlResolver\MinifyResolver { 274 | return UrlResolver\MinifyResolver::new(); 275 | } 276 | ); 277 | } 278 | 279 | /** 280 | * @return bool 281 | */ 282 | public function hasManifest(): bool 283 | { 284 | $this->hasManifest ??= file_exists($this->context()->manifestJsonPath()); 285 | 286 | return $this->hasManifest; 287 | } 288 | 289 | /** 290 | * @template T of object 291 | * 292 | * @param class-string $class 293 | * @param callable(Factory):T $defaultFactory 294 | * @return T 295 | */ 296 | private function factoryObject(string $class, callable $defaultFactory): object 297 | { 298 | if (!isset($this->objects[$class])) { 299 | /** @var callable(Factory):T $factory */ 300 | $factory = $this->factories[$class] ?? $defaultFactory; 301 | $object = $factory($this); 302 | $filtered = apply_filters("brain.assets.factory.{$class}", $object, $this); 303 | if (is_a($filtered, $class)) { 304 | $object = $filtered; 305 | } 306 | /** @var T $object */ 307 | $this->objects[$class] = $object; 308 | } 309 | /** @var T */ 310 | return $this->objects[$class]; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/UrlResolver/DirectUrlResolver.php: -------------------------------------------------------------------------------- 1 | findCandidates($path, $minifyResolver?->resolve($path)); 38 | 39 | $fullUrl = ''; 40 | /** @var list $candidates */ 41 | foreach ($candidates as [$candidatePath, $pathBasePath, $pathBaseUrl]) { 42 | if (file_exists($pathBasePath . $candidatePath)) { 43 | $fullUrl = $pathBaseUrl . $candidatePath; 44 | break; 45 | } 46 | } 47 | 48 | $baseUrl = $this->context->baseUrl(); 49 | if (($fullUrl === '') && ($relative !== '')) { 50 | $fullUrl = $baseUrl . ltrim($relative, '/'); 51 | } 52 | 53 | $query = $urlData['query'] ?? ''; 54 | if (($fullUrl !== '') && ($query !== '')) { 55 | $fullUrl .= "?{$query}"; 56 | } 57 | 58 | return $fullUrl; 59 | } 60 | 61 | /** 62 | * @param string $path 63 | * @param string|null $minifiedPath 64 | * @return list 65 | */ 66 | private function findCandidates(string $path, ?string $minifiedPath): array 67 | { 68 | $baseUrl = $this->context->baseUrl(); 69 | $basePath = $this->context->basePath(); 70 | 71 | $hasMin = ($minifiedPath !== '') && ($minifiedPath !== null); 72 | $hasPath = ($path !== '') && ($path !== $minifiedPath); 73 | 74 | $candidates = $hasMin ? [[$minifiedPath, $basePath, $baseUrl]] : []; 75 | $hasPath and $candidates[] = [$path, $basePath, $baseUrl]; 76 | 77 | if (($hasMin || $hasPath) && $this->context->hasAlternative()) { 78 | $altBasePath = $this->context->altBasePath() ?? ''; 79 | $altBaseUrl = $this->context->altBaseUrl() ?? ''; 80 | if (($altBasePath !== '') && ($altBaseUrl !== '')) { 81 | $hasMin and $candidates[] = [$minifiedPath, $altBasePath, $altBaseUrl]; 82 | $hasPath and $candidates[] = [$path, $altBasePath, $altBaseUrl]; 83 | } 84 | } 85 | /** @var list $candidates */ 86 | return $candidates; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/UrlResolver/ManifestUrlResolver.php: -------------------------------------------------------------------------------- 1 | determinePaths($manifestPath); 41 | } 42 | 43 | /** 44 | * @param string $relative 45 | * @param MinifyResolver|null $minifyResolver 46 | * @return string 47 | */ 48 | public function resolve(string $relative, ?MinifyResolver $minifyResolver): string 49 | { 50 | if (!$this->paths) { 51 | return $this->directResolver->resolve($relative, $minifyResolver); 52 | } 53 | 54 | $path = trim((string) parse_url($relative, PHP_URL_PATH), '/'); 55 | $manifestPath = $this->paths[$path] ?? null; 56 | 57 | if (($manifestPath === '') || !is_string($manifestPath)) { 58 | $manifestPath = $path; 59 | } 60 | 61 | return $this->directResolver->resolve($manifestPath, $minifyResolver); 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function resolveAll(): array 68 | { 69 | $found = []; 70 | foreach ($this->paths as $name => $path) { 71 | if ( 72 | ($name === '') 73 | || ($path === '') 74 | || !is_string($name) 75 | || !is_string($path) 76 | || (preg_match('~^.+?\.(?:css|js)$~i', $path) === false) 77 | ) { 78 | continue; 79 | } 80 | $url = $this->directResolver->resolve($path, null); 81 | ($url !== '') and $found[$name] = $url; 82 | } 83 | 84 | return $found; 85 | } 86 | 87 | /** 88 | * @param string $manifestPath 89 | * @return void 90 | */ 91 | private function determinePaths(string $manifestPath): void 92 | { 93 | if (!is_readable($manifestPath)) { 94 | return; 95 | } 96 | 97 | try { 98 | $content = @file_get_contents($manifestPath); 99 | $paths = (($content !== false) && ($content !== '')) 100 | ? json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR) 101 | : null; 102 | if (!is_array($paths)) { 103 | return; 104 | } 105 | foreach ($paths as $name => $path) { 106 | if (($name === '') || ($path === '') || !is_string($name) || !is_string($path)) { 107 | continue; 108 | } 109 | $name = ltrim($name, './'); 110 | $path = ltrim($path, './'); 111 | if (($name !== '') && ($path !== '')) { 112 | $this->paths[$name] = $path; 113 | } 114 | } 115 | } catch (\Throwable) { 116 | // silence 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/UrlResolver/MinifyResolver.php: -------------------------------------------------------------------------------- 1 | .+?)(?P\.min)?\.(?Pjs|css)\$~i", $path, $matches)) { 40 | return null; 41 | } 42 | 43 | if (($matches['min'] ?? '') !== '') { 44 | return null; 45 | } 46 | 47 | return "{$matches['file']}.min.{$matches['ext']}"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/UrlResolver/UrlResolver.php: -------------------------------------------------------------------------------- 1 | */ 20 | private static array $cache = []; 21 | 22 | /** 23 | * @param PathFinder $pathFinder 24 | * @return static 25 | */ 26 | public static function new(PathFinder $pathFinder): static 27 | { 28 | return new static($pathFinder); 29 | } 30 | 31 | /** 32 | * @param PathFinder $pathFinder 33 | */ 34 | final protected function __construct( 35 | private PathFinder $pathFinder, 36 | ) { 37 | } 38 | 39 | /** 40 | * @param string $url 41 | * @return string|null 42 | */ 43 | public function readVersion(string $url): ?string 44 | { 45 | $fullPath = $this->pathFinder->findPath($url); 46 | 47 | if ($fullPath === null) { 48 | return null; 49 | } 50 | 51 | return $this->loadDataForPath($fullPath)['version'] ?? null; 52 | } 53 | 54 | /** 55 | * @param string $url 56 | * @return array 57 | */ 58 | public function readDependencies(string $url): array 59 | { 60 | $fullPath = $this->pathFinder->findPath($url); 61 | 62 | if ($fullPath === null) { 63 | return []; 64 | } 65 | 66 | return $this->loadDataForPath($fullPath)['dependencies'] ?? []; 67 | } 68 | 69 | /** 70 | * @param string $fullPath 71 | * @param bool $secondAttemp 72 | * @return array{version?: non-empty-string, dependencies?: non-empty-array} 73 | */ 74 | private function loadDataForPath(string $fullPath, bool $secondAttempt = false): array 75 | { 76 | if (isset(static::$cache[$fullPath])) { 77 | return static::$cache[$fullPath]; 78 | } 79 | 80 | $phpPath = preg_replace('~\.([a-z0-9_-]+)$~i', '.asset.php', $fullPath); 81 | if (!file_exists($phpPath) || !is_readable($phpPath)) { 82 | if ( 83 | !$secondAttempt 84 | && preg_match('~^(.+?)\.[^\.]+(\.(?:css|js))$~i', $fullPath, $matches) 85 | && file_exists($fullPath) 86 | ) { 87 | $fullPath = $matches[1] . $matches[2]; 88 | 89 | return $this->loadDataForPath($fullPath, true); 90 | } 91 | static::$cache[$fullPath] = []; 92 | 93 | return []; 94 | } 95 | 96 | try { 97 | $data = include $phpPath; 98 | if (!is_array($data)) { 99 | static::$cache[$fullPath] = []; 100 | 101 | return []; 102 | } 103 | $version = $data['version'] ?? null; 104 | $deps = $data['dependencies'] ?? null; 105 | $loaded = []; 106 | (is_string($version) && ($version !== '')) and $loaded['version'] = $version; 107 | (is_array($deps) && ($deps !== [])) and $loaded['dependencies'] = $deps; 108 | static::$cache[$fullPath] = $loaded; 109 | 110 | return $loaded; 111 | } catch (\Throwable) { 112 | return []; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Utils/PathFinder.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private array $cache; 28 | 29 | /** @var array */ 30 | private array $bases; 31 | 32 | /** 33 | * @param Context $context 34 | * @return static 35 | */ 36 | public static function new(Context $context): static 37 | { 38 | return new static($context); 39 | } 40 | 41 | /** 42 | * @param Context $context 43 | * @param Version|null $version 44 | */ 45 | final protected function __construct(Context $context) 46 | { 47 | $this->cache = []; 48 | $this->bases = []; 49 | 50 | /** @var non-falsy-string $baseUrl */ 51 | $baseUrl = strtok($context->baseUrl(), '?'); 52 | /** @var non-falsy-string $basePath */ 53 | $basePath = wp_normalize_path($context->basePath()); 54 | 55 | $this->bases[$baseUrl] = [$basePath, strlen($baseUrl)]; 56 | if (!$context->hasAlternative()) { 57 | return; 58 | } 59 | 60 | $altBaseUrl = $context->altBaseUrl(); 61 | $altBasePath = $context->altBasePath(); 62 | if (($altBasePath !== null) && ($altBaseUrl !== null)) { 63 | $altBaseUrl = strtok($altBaseUrl, '?'); 64 | /** @var non-falsy-string $altBaseUrl */ 65 | $this->bases[$altBaseUrl] = [$altBasePath, strlen($altBaseUrl)]; 66 | } 67 | } 68 | 69 | /** 70 | * @param string $url 71 | * @return non-falsy-string|null 72 | */ 73 | public function findPath(string $url): ?string 74 | { 75 | return $this->findPathInfo($url)[0]; 76 | } 77 | 78 | /** 79 | * @param string $url 80 | * @return list{ 81 | * non-falsy-string|null, 82 | * string|null, 83 | * non-falsy-string|null, 84 | * non-falsy-string|null 85 | * } 86 | */ 87 | public function findPathInfo(string $url): array 88 | { 89 | if (isset($this->cache[$url])) { 90 | return $this->cache[$url]; 91 | } 92 | 93 | $theBaseUrl = null; 94 | $theBasePath = null; 95 | $relPath = null; 96 | $fullPath = null; 97 | 98 | foreach ($this->bases as $baseUrl => [$urlBasePath, $baseUrlLength]) { 99 | $normUrl = strtok($this->matchUrlScheme($baseUrl, $url), '?'); 100 | if (($normUrl !== false) && substr($normUrl, 0, $baseUrlLength) === $baseUrl) { 101 | $theBaseUrl = $baseUrl; 102 | $theBasePath = $urlBasePath; 103 | $relPath = substr($normUrl, $baseUrlLength); 104 | $fullPath = $urlBasePath . $relPath; 105 | break; 106 | } 107 | } 108 | 109 | $this->cache[$url] = [$fullPath, $relPath, $theBaseUrl, $theBasePath]; 110 | 111 | return $this->cache[$url]; 112 | } 113 | 114 | /** 115 | * @param string $sourceUrl 116 | * @param string $targetUrl 117 | * @return string 118 | */ 119 | private function matchUrlScheme(string $sourceUrl, string $targetUrl): string 120 | { 121 | $leftScheme = parse_url($sourceUrl, PHP_URL_SCHEME); 122 | $rightScheme = parse_url($targetUrl, PHP_URL_SCHEME); 123 | 124 | return ($leftScheme !== $rightScheme) 125 | ? (string) set_url_scheme($targetUrl, $leftScheme) 126 | : $targetUrl; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Version/LastModifiedVersion.php: -------------------------------------------------------------------------------- 1 | pathFinder->findPath($url); 49 | if ($fullPath === null) { 50 | return null; 51 | } 52 | 53 | $lastModified = @filemtime($fullPath); 54 | 55 | return ($lastModified !== false) ? (string) $lastModified : null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Version/Version.php: -------------------------------------------------------------------------------- 1 |