├── src ├── Exception │ ├── FileNotFoundException.php │ ├── InvalidArgumentException.php │ ├── InvalidResourceException.php │ └── MissingArgumentException.php ├── Loader │ ├── LoaderInterface.php │ ├── PhpFileLoader.php │ ├── ArrayLoader.php │ ├── EncoreEntrypointsLoader.php │ ├── WebpackManifestLoader.php │ └── AbstractWebpackLoader.php ├── OutputFilter │ ├── AssetOutputFilter.php │ ├── AsyncScriptOutputFilter.php │ ├── DeferScriptOutputFilter.php │ ├── InlineAssetOutputFilter.php │ ├── AttributesOutputFilter.php │ └── AsyncStyleOutputFilter.php ├── Handler │ ├── AssetHandler.php │ ├── OutputFilterAwareAssetHandler.php │ ├── ScriptModuleHandler.php │ ├── StyleHandler.php │ ├── OutputFilterAwareAssetHandlerTrait.php │ └── ScriptHandler.php ├── DataAwareAsset.php ├── FilterAwareAsset.php ├── DataAwareTrait.php ├── ConfigureAutodiscoverVersionTrait.php ├── ScriptModule.php ├── Util │ ├── AssetHookResolver.php │ └── AssetPathResolver.php ├── FilterAwareTrait.php ├── AssetCollection.php ├── DependencyExtractionTrait.php ├── Style.php ├── Asset.php ├── Script.php ├── BaseAsset.php ├── AssetManager.php └── AssetFactory.php ├── phpstan.neon.dist ├── inc ├── bootstrap.php └── functions.php ├── composer.json └── LICENSE /src/Exception/FileNotFoundException.php: -------------------------------------------------------------------------------- 1 | true']); 11 | */ 12 | class AsyncScriptOutputFilter implements AssetOutputFilter 13 | { 14 | public function __invoke(string $html, FilterAwareAsset $asset): string 15 | { 16 | return str_replace('', 36 | $asset->version(), 37 | $asset->handle(), 38 | $content 39 | ); 40 | } 41 | 42 | if ($asset instanceof Style) { 43 | return sprintf( 44 | '', 45 | $asset->version(), 46 | $asset->handle(), 47 | $content 48 | ); 49 | } 50 | 51 | return $html; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/OutputFilter/AttributesOutputFilter.php: -------------------------------------------------------------------------------- 1 | attributes(); 14 | if (!class_exists(\WP_HTML_Tag_Processor::class) || count($attributes) === 0) { 15 | return $html; 16 | } 17 | 18 | $tags = new \WP_HTML_Tag_Processor($html); 19 | 20 | // Only extend before and after. 22 | if ( 23 | $tags->next_tag(['tag_name' => 'script']) 24 | && (string) $tags->get_attribute('src') 25 | ) { 26 | $this->applyAttributes($tags, $attributes); 27 | } 28 | 29 | return $tags->get_updated_html(); 30 | } 31 | 32 | /** 33 | * @param \WP_HTML_Tag_Processor $script 34 | * @param array $attributes 35 | * 36 | * @return void 37 | */ 38 | protected function applyAttributes(\WP_HTML_Tag_Processor $script, array $attributes): void 39 | { 40 | foreach ($attributes as $key => $value) { 41 | $key = esc_attr((string) $key); 42 | if ((string) $script->get_attribute($key)) { 43 | continue; 44 | } 45 | if (is_bool($value) && !$value) { 46 | continue; 47 | } 48 | $value = is_bool($value) 49 | ? esc_attr($key) 50 | : esc_attr((string) $value); 51 | 52 | $script->set_attribute($key, $value); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /inc/bootstrap.php: -------------------------------------------------------------------------------- 1 | setup(); 24 | 25 | return $done; 26 | } 27 | 28 | /* 29 | * This file is loaded by Composer autoload, and that may happen before `add_action` is available. 30 | * In that case, we first try to load `plugin.php` before calling `add_action`. 31 | */ 32 | 33 | $addActionExists = function_exists('add_action'); 34 | if ( 35 | $addActionExists 36 | || (defined('ABSPATH') && defined('WP_INC') && file_exists(ABSPATH . WP_INC . '/plugin.php')) 37 | ) { 38 | if (!$addActionExists) { 39 | require_once ABSPATH . WP_INC . '/plugin.php'; 40 | } 41 | 42 | unset($addActionExists); 43 | add_action('wp_loaded', __NAMESPACE__ . '\\bootstrap', 99); 44 | 45 | return; 46 | } 47 | 48 | unset($addActionExists); 49 | 50 | /** 51 | * If here, this file is loaded very early, probably too-much early, even before ABSPATH was defined 52 | * so only option we have is to "manually" write in global `$wp_filter` array. 53 | */ 54 | 55 | global $wp_filter; 56 | is_array($wp_filter) or $wp_filter = []; 57 | isset($wp_filter['wp_loaded']) or $wp_filter['wp_loaded'] = []; 58 | isset($wp_filter['wp_loaded'][99]) or $wp_filter['wp_loaded'][99] = []; 59 | $wp_filter['wp_loaded'][99][__NAMESPACE__ . '\\bootstrap'] = [ 60 | 'function' => __NAMESPACE__ . '\\bootstrap', 61 | 'accepted_args' => 0, 62 | ]; 63 | -------------------------------------------------------------------------------- /src/Handler/ScriptModuleHandler.php: -------------------------------------------------------------------------------- 1 | register($asset); 22 | 23 | if ($asset->enqueue()) { 24 | wp_enqueue_script_module($asset->handle()); 25 | 26 | return true; 27 | } 28 | 29 | return false; 30 | } 31 | 32 | public function register(Asset $asset): bool 33 | { 34 | if (!$asset instanceof ScriptModule) { 35 | return false; 36 | } 37 | if (!static::scriptModulesSupported()) { 38 | return false; 39 | } 40 | 41 | $handle = $asset->handle(); 42 | 43 | $this->shareData($asset); 44 | 45 | wp_register_script_module( 46 | $handle, 47 | $asset->url(), 48 | $asset->dependencies(), // @phpstan-ignore-line 49 | $asset->version() 50 | ); 51 | 52 | return true; 53 | } 54 | 55 | protected static function scriptModulesSupported(): bool 56 | { 57 | return class_exists('WP_Script_Modules'); 58 | } 59 | 60 | protected function shareData(ScriptModule $asset): void 61 | { 62 | $handle = $asset->handle(); 63 | 64 | if (!$asset->data()) { 65 | return; 66 | } 67 | 68 | add_filter( 69 | "script_module_data_{$handle}", 70 | static function () use ($asset): array { 71 | return $asset->data(); 72 | }, 73 | PHP_INT_MAX - 10 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /inc/functions.php: -------------------------------------------------------------------------------- 1 | context = $context ?? WpContext::determine(); 20 | } 21 | 22 | /** 23 | * Resolving to the current location/page in WordPress all current hooks. 24 | * 25 | * @return string[] 26 | */ 27 | public function resolve(): array 28 | { 29 | $isLogin = $this->context->isLogin(); 30 | $isFront = $this->context->isFrontoffice(); 31 | $isActivate = $this->context->isWpActivate(); 32 | 33 | if (!$isActivate && !$isLogin && !$isFront && !$this->context->isBackoffice()) { 34 | return []; 35 | } 36 | 37 | if ($isLogin) { 38 | return [Asset::HOOK_LOGIN]; 39 | } 40 | 41 | if ($isActivate) { 42 | return [Asset::HOOK_ACTIVATE]; 43 | } 44 | 45 | // These hooks might be fired in both front and back office. 46 | $assets = [Asset::HOOK_BLOCK_ASSETS]; 47 | 48 | if ($isFront) { 49 | $assets[] = Asset::HOOK_FRONTEND; 50 | $assets[] = Asset::HOOK_CUSTOMIZER_PREVIEW; 51 | 52 | return $assets; 53 | } 54 | 55 | $assets[] = Asset::HOOK_BLOCK_EDITOR_ASSETS; 56 | $assets[] = Asset::HOOK_CUSTOMIZER; 57 | $assets[] = Asset::HOOK_BACKEND; 58 | 59 | return $assets; 60 | } 61 | 62 | /** 63 | * @return string|null 64 | */ 65 | public function lastHook(): ?string 66 | { 67 | switch (true) { 68 | case $this->context->isLogin(): 69 | return Asset::HOOK_LOGIN; 70 | case $this->context->isFrontoffice(): 71 | return Asset::HOOK_FRONTEND; 72 | case $this->context->isBackoffice(): 73 | return Asset::HOOK_BACKEND; 74 | case $this->context->isWpActivate(): 75 | return Asset::HOOK_ACTIVATE; 76 | } 77 | 78 | return null; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/FilterAwareTrait.php: -------------------------------------------------------------------------------- 1 | [] 15 | */ 16 | protected array $filters = []; 17 | 18 | /** 19 | * Additional attributes to "link"- or "script"-tag. 20 | * 21 | * @var array 22 | */ 23 | protected array $attributes = []; 24 | 25 | /** 26 | * @return callable[]|AssetOutputFilter[]|class-string[] 27 | */ 28 | public function filters(): array 29 | { 30 | return $this->filters; 31 | } 32 | 33 | /** 34 | * @param callable|class-string ...$filters 35 | * 36 | * @return static 37 | * 38 | * phpcs:disable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 39 | */ 40 | public function withFilters(...$filters): Asset 41 | { 42 | // phpcs:enable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 43 | 44 | $this->filters = array_merge($this->filters, $filters); 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Shortcut to use the InlineFilter. 51 | * 52 | * @return static 53 | */ 54 | public function useInlineFilter(): Asset 55 | { 56 | $this->withFilters(InlineAssetOutputFilter::class); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function attributes(): array 65 | { 66 | return $this->attributes; 67 | } 68 | 69 | /** 70 | * Allows you to set additional attributes to your "link"- or "script"-tag. 71 | * Existing attributes like "src" or "id" will not be overwrite. 72 | * 73 | * @param array $attributes 74 | * 75 | * @return static 76 | */ 77 | public function withAttributes(array $attributes): Asset 78 | { 79 | $this->attributes = array_merge($this->attributes, $attributes); 80 | $this->withFilters(AttributesOutputFilter::class); 81 | 82 | return $this; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/AssetCollection.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | class AssetCollection 11 | { 12 | /** 13 | * @var Assets 14 | */ 15 | protected array $assets = []; 16 | 17 | /** 18 | * @param Asset $asset 19 | * 20 | * @return void 21 | */ 22 | public function add(Asset $asset): void 23 | { 24 | $type = get_class($asset); 25 | $handle = $asset->handle(); 26 | $this->assets[$type][$handle] = $asset; 27 | } 28 | 29 | /** 30 | * @param string $handle 31 | * @param class-string $type 32 | * 33 | * @return Asset|null 34 | */ 35 | public function get(string $handle, string $type): ?Asset 36 | { 37 | $found = null; 38 | foreach ($this->assets as $assets) { 39 | foreach ($assets as $asset) { 40 | if ($asset->handle() !== $handle) { 41 | continue; 42 | } 43 | if (is_a($asset, $type)) { 44 | $found = $asset; 45 | break 2; 46 | } 47 | } 48 | } 49 | 50 | return $found; 51 | } 52 | 53 | /** 54 | * @param string $handle 55 | * 56 | * @return Asset|null 57 | * 58 | * phpcs:disable Syde.Classes.DisallowGetterSetter.GetterFound 59 | */ 60 | public function getFirst(string $handle): ?Asset 61 | { 62 | $found = null; 63 | foreach ($this->assets as $assets) { 64 | foreach ($assets as $asset) { 65 | if ($asset->handle() === $handle) { 66 | $found = $asset; 67 | break 2; 68 | } 69 | } 70 | } 71 | 72 | return $found; 73 | } 74 | 75 | /** 76 | * @param string $handle 77 | * @param class-string $type 78 | * 79 | * @return bool 80 | */ 81 | public function has(string $handle, string $type): bool 82 | { 83 | return $this->get($handle, $type) !== null; 84 | } 85 | 86 | /** 87 | * @return Assets 88 | */ 89 | public function all(): array 90 | { 91 | return $this->assets; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/OutputFilter/AsyncStyleOutputFilter.php: -------------------------------------------------------------------------------- 1 | url(); 32 | $version = $asset->version(); 33 | if ($version) { 34 | $url = add_query_arg('ver', $version, $url); 35 | } 36 | 37 | ob_start(); 38 | ?> 39 | 40 | 41 | polyfillPrinted) { 47 | $output .= ""; 48 | $this->polyfillPrinted = true; 49 | } 50 | 51 | return $output; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Loader/EncoreEntrypointsLoader.php: -------------------------------------------------------------------------------- 1 | $filesByExtension) { 28 | $files = $filesByExtension['css'] ?? []; 29 | $assets = array_merge($assets, $this->extractAssets($handle, $files, $directory)); 30 | 31 | $files = $filesByExtension['js'] ?? []; 32 | $assets = array_merge($assets, $this->extractAssets($handle, $files, $directory)); 33 | } 34 | 35 | return $assets; 36 | } 37 | 38 | /** 39 | * @param string $handle 40 | * @param string[] $files 41 | * @param string $directory 42 | * 43 | * @return Asset[] 44 | */ 45 | protected function extractAssets(string $handle, array $files, string $directory): array 46 | { 47 | $assets = []; 48 | 49 | foreach ($files as $i => $file) { 50 | $handle = $i > 0 51 | ? "{$handle}-{$i}" 52 | : $handle; 53 | 54 | $sanitizedFile = $this->sanitizeFileName($file); 55 | 56 | $fileUrl = (!$this->directoryUrl) 57 | ? $file 58 | : $this->directoryUrl . $sanitizedFile; 59 | 60 | $filePath = $directory . $sanitizedFile; 61 | 62 | $asset = $this->buildAsset($handle, $fileUrl, $filePath); 63 | 64 | if ($asset !== null) { 65 | $assets[] = $asset; 66 | } 67 | } 68 | 69 | foreach ($assets as $i => $asset) { 70 | $dependencies = array_map( 71 | static function (Asset $asset): string { 72 | return $asset->handle(); 73 | }, 74 | array_slice($assets, 0, $i) 75 | ); 76 | $asset->withDependencies(...$dependencies); 77 | } 78 | 79 | return $assets; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Handler/StyleHandler.php: -------------------------------------------------------------------------------- 1 | $outputFilters 24 | */ 25 | public function __construct(\WP_Styles $wpStyles, array $outputFilters = []) 26 | { 27 | $this->withOutputFilter(AsyncStyleOutputFilter::class, new AsyncStyleOutputFilter()); 28 | $this->withOutputFilter(InlineAssetOutputFilter::class, new InlineAssetOutputFilter()); 29 | $this->withOutputFilter(AttributesOutputFilter::class, new AttributesOutputFilter()); 30 | 31 | $this->wpStyles = $wpStyles; 32 | foreach ($outputFilters as $name => $callable) { 33 | $this->withOutputFilter($name, $callable); 34 | } 35 | } 36 | 37 | public function enqueue(Asset $asset): bool 38 | { 39 | $this->register($asset); 40 | 41 | if ($asset->enqueue()) { 42 | wp_enqueue_style($asset->handle()); 43 | 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | public function register(Asset $asset): bool 51 | { 52 | /** @var Style $asset */ 53 | 54 | $handle = $asset->handle(); 55 | wp_register_style( 56 | $handle, 57 | $asset->url(), 58 | $asset->dependencies(), 59 | $asset->version(), 60 | $asset->media() 61 | ); 62 | 63 | $inlineStyles = $asset->inlineStyles(); 64 | if ($inlineStyles !== null) { 65 | wp_add_inline_style($handle, implode("\n", $inlineStyles)); 66 | } 67 | 68 | $cssVars = $asset->cssVars(); 69 | if (count($cssVars) > 0) { 70 | wp_add_inline_style($handle, $asset->cssVarsAsString()); 71 | } 72 | 73 | if (count($asset->data()) > 0) { 74 | foreach ($asset->data() as $key => $value) { 75 | $this->wpStyles->add_data($handle, $key, $value); 76 | } 77 | } 78 | 79 | return true; 80 | } 81 | 82 | public function filterHook(): string 83 | { 84 | return 'style_loader_tag'; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inpsyde/assets", 3 | "description": "Package to manage assets in WordPress.", 4 | "type": "library", 5 | "license": "GPL-2.0-or-later", 6 | "authors": [ 7 | { 8 | "name": "Syde GmbH", 9 | "homepage": "https://syde.com/", 10 | "email": "hello@syde.com", 11 | "role": "Company" 12 | }, 13 | { 14 | "name": "Christian Leucht", 15 | "email": "c.leucht@syde.com", 16 | "homepage": "https://www.chrico.info", 17 | "role": "Developer" 18 | } 19 | ], 20 | "minimum-stability": "dev", 21 | "prefer-stable": true, 22 | "require": { 23 | "php": ">=7.4", 24 | "ext-json": "*", 25 | "ext-dom": "*", 26 | "inpsyde/wp-context": "^1.3" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^8.5.14 || ^9.0", 30 | "brain/monkey": "^2.5.0", 31 | "mikey179/vfsstream": "^1.6.8", 32 | "syde/phpcs": "^1.0.0", 33 | "phpstan/phpstan": "^2.1.1", 34 | "phpstan/phpstan-mockery": "^2.0.0", 35 | "phpstan/phpstan-phpunit": "^2.0.4", 36 | "szepeviktor/phpstan-wordpress": "^2", 37 | "swissspidy/phpstan-no-private": "^v1.0.0", 38 | "phpstan/phpstan-deprecation-rules": "^2.0.1", 39 | "php-stubs/wordpress-stubs": ">=6.2@stable", 40 | "johnpbloch/wordpress-core": ">=6.7" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Inpsyde\\Assets\\": "src/" 45 | }, 46 | "files": [ 47 | "inc/functions.php", 48 | "inc/bootstrap.php" 49 | ] 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Inpsyde\\Assets\\Tests\\Unit\\": "tests/phpunit/Unit/" 54 | } 55 | }, 56 | "scripts": { 57 | "phpcs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs", 58 | "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=1G", 59 | "tests": "@php ./vendor/phpunit/phpunit/phpunit", 60 | "tests:no-cov": "@php ./vendor/phpunit/phpunit/phpunit --no-coverage", 61 | "tests:codecov": "@php ./vendor/phpunit/phpunit/phpunit --coverage-clover coverage.xml", 62 | "qa": [ 63 | "@phpcs", 64 | "@phpstan", 65 | "@tests:no-cov" 66 | ] 67 | }, 68 | "config": { 69 | "optimize-autoloader": true, 70 | "allow-plugins": { 71 | "roots/wordpress-core-installer": true, 72 | "dealerdirect/phpcodesniffer-composer-installer": true 73 | } 74 | }, 75 | "extra": { 76 | "wordpress-install-dir": "vendor/wordpress/wordpress" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Handler/OutputFilterAwareAssetHandlerTrait.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | protected array $outputFilters = []; 17 | 18 | /** 19 | * @param string $name 20 | * @param callable $filter 21 | * 22 | * @return OutputFilterAwareAssetHandler 23 | */ 24 | public function withOutputFilter(string $name, callable $filter): OutputFilterAwareAssetHandler 25 | { 26 | $this->outputFilters[$name] = $filter; 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * @return array> 33 | */ 34 | public function outputFilters(): array 35 | { 36 | return $this->outputFilters; 37 | } 38 | 39 | /** 40 | * @param Asset $asset 41 | * 42 | * @return bool 43 | */ 44 | public function filter(Asset $asset): bool 45 | { 46 | $filters = $this->currentOutputFilters($asset); 47 | if (count($filters) === 0) { 48 | return false; 49 | } 50 | 51 | add_filter( 52 | $this->filterHook(), 53 | static function (string $html, string $handle) use ($filters, $asset): string { 54 | if ($handle !== $asset->handle()) { 55 | return $html; 56 | } 57 | foreach ($filters as $filter) { 58 | if (!is_callable($filter)) { 59 | continue; 60 | } 61 | $html = (string) $filter($html, $asset); 62 | } 63 | 64 | return $html; 65 | }, 66 | 10, 67 | 2 68 | ); 69 | 70 | return true; 71 | } 72 | 73 | /** 74 | * @param Asset $asset 75 | * 76 | * @return array|callable> 77 | */ 78 | protected function currentOutputFilters(Asset $asset): array 79 | { 80 | $filters = []; 81 | $registeredFilters = $this->outputFilters(); 82 | 83 | if (!$asset instanceof FilterAwareAsset) { 84 | return $filters; 85 | } 86 | 87 | foreach ($asset->filters() as $filter) { 88 | if (is_callable($filter)) { 89 | $filters[] = $filter; 90 | continue; 91 | } 92 | if (isset($registeredFilters[$filter])) { 93 | $filters[] = $registeredFilters[$filter]; 94 | } 95 | } 96 | 97 | return $filters; 98 | } 99 | 100 | /** 101 | * Defines the name of hook to filter the specific asset. 102 | * 103 | * @return string 104 | */ 105 | abstract public function filterHook(): string; 106 | } 107 | -------------------------------------------------------------------------------- /src/DependencyExtractionTrait.php: -------------------------------------------------------------------------------- 1 | resolvedDependencyExtractionPlugin) { 23 | return false; 24 | } 25 | $this->resolvedDependencyExtractionPlugin = true; 26 | 27 | $depsFile = $this->findDepdendencyFile(); 28 | if (!$depsFile) { 29 | return false; 30 | } 31 | 32 | $depsFilePath = $depsFile->getPathname(); 33 | $data = $depsFile->getExtension() === 'json' 34 | ? @json_decode((string) @file_get_contents($depsFilePath), true) 35 | : @require $depsFilePath; 36 | 37 | /** @var string[] $dependencies */ 38 | $dependencies = $data['dependencies'] ?? []; 39 | /** @var string|null $version */ 40 | $version = $data['version'] ?? null; 41 | 42 | $this->withDependencies(...$dependencies); 43 | if (!$this->version && $version) { 44 | $this->withVersion($version); 45 | } 46 | 47 | return true; 48 | } 49 | 50 | /** 51 | * Searching for in directory of the asset: 52 | * 53 | * - {fileName}.asset.json 54 | * - {fileName}.{hash}.asset.json 55 | * - {fileName}.asset.php 56 | * - {fileName}.{hash}.asset.php 57 | * 58 | * @return \DirectoryIterator|null 59 | */ 60 | protected function findDepdendencyFile(): ?\DirectoryIterator 61 | { 62 | try { 63 | $filePath = $this->filePath(); 64 | if ($filePath === '') { 65 | return null; 66 | } 67 | 68 | $path = dirname($filePath) . '/'; 69 | 70 | $fileName = str_replace([$path, '.js'], '', $filePath); 71 | // It might be possible that the script file contains a version hash as well. 72 | // So we need to split it apart and just use the first part of the file. 73 | $fileNamePieces = explode('.', $fileName); 74 | $fileName = $fileNamePieces[0]; 75 | 76 | $regex = '/' . $fileName . '(?:\.[a-zA-Z0-9]+)?\.asset\.(json|php)/'; 77 | 78 | $depsFile = null; 79 | foreach (new \DirectoryIterator($path) as $fileInfo) { 80 | if ( 81 | $fileInfo->isDot() 82 | || $fileInfo->isDir() 83 | || !in_array($fileInfo->getExtension(), ['json', 'php'], true) 84 | ) { 85 | continue; 86 | } 87 | if (preg_match($regex, $fileInfo->getFilename())) { 88 | $depsFile = $fileInfo; 89 | break; 90 | } 91 | } 92 | 93 | return $depsFile; 94 | } catch (\Throwable $exception) { 95 | return null; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Handler/ScriptHandler.php: -------------------------------------------------------------------------------- 1 | $outputFilters 26 | */ 27 | public function __construct(WP_Scripts $wpScripts, array $outputFilters = []) 28 | { 29 | $this->withOutputFilter(AsyncScriptOutputFilter::class, new AsyncScriptOutputFilter()); 30 | $this->withOutputFilter(DeferScriptOutputFilter::class, new DeferScriptOutputFilter()); 31 | $this->withOutputFilter(InlineAssetOutputFilter::class, new InlineAssetOutputFilter()); 32 | $this->withOutputFilter(AttributesOutputFilter::class, new AttributesOutputFilter()); 33 | 34 | $this->wpScripts = $wpScripts; 35 | foreach ($outputFilters as $name => $callable) { 36 | $this->withOutputFilter($name, $callable); 37 | } 38 | } 39 | 40 | public function enqueue(Asset $asset): bool 41 | { 42 | $this->register($asset); 43 | 44 | if ($asset->enqueue()) { 45 | wp_enqueue_script($asset->handle()); 46 | 47 | return true; 48 | } 49 | 50 | return false; 51 | } 52 | 53 | public function register(Asset $asset): bool 54 | { 55 | /** @var Script $asset */ 56 | 57 | $handle = $asset->handle(); 58 | 59 | wp_register_script( 60 | $handle, 61 | $asset->url(), 62 | $asset->dependencies(), 63 | $asset->version(), 64 | $asset->inFooter() 65 | ); 66 | 67 | if (count($asset->localize()) > 0) { 68 | foreach ($asset->localize() as $name => $args) { 69 | /** 70 | * Actually it is possible to use $args as scalar value for 71 | * \WP_Scripts::localize() - but it will produce a _doing_it_wrong(). 72 | * 73 | * @psalm-suppress MixedArgument 74 | */ 75 | wp_localize_script($handle, $name, $args); 76 | } 77 | } 78 | 79 | foreach ($asset->inlineScripts() as $location => $data) { 80 | if (count($data) > 0) { 81 | wp_add_inline_script($handle, implode("\n", $data), $location); 82 | } 83 | } 84 | 85 | $translation = $asset->translation(); 86 | if ($translation['domain'] !== '') { 87 | /** 88 | * The $path is allowed to be "null"- or a "string"-value. 89 | * @psalm-suppress PossiblyNullArgument 90 | */ 91 | wp_set_script_translations($handle, $translation['domain'], $translation['path']); 92 | } 93 | 94 | if (count($asset->data()) > 0) { 95 | foreach ($asset->data() as $key => $value) { 96 | $this->wpScripts->add_data($handle, $key, $value); 97 | } 98 | } 99 | 100 | return true; 101 | } 102 | 103 | public function filterHook(): string 104 | { 105 | return 'script_loader_tag'; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Style.php: -------------------------------------------------------------------------------- 1 | > 28 | */ 29 | protected array $cssVars = []; 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function media(): string 35 | { 36 | return $this->media; 37 | } 38 | 39 | /** 40 | * @param string $media 41 | * 42 | * @return static 43 | */ 44 | public function forMedia(string $media): Style 45 | { 46 | $this->media = $media; 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * @return string[]|null 53 | */ 54 | public function inlineStyles(): ?array 55 | { 56 | return $this->inlineStyles; 57 | } 58 | 59 | /** 60 | * @param string $inline 61 | * 62 | * @return static 63 | * 64 | * @see https://codex.wordpress.org/Function_Reference/wp_add_inline_style 65 | */ 66 | public function withInlineStyles(string $inline): Style 67 | { 68 | if (!$this->inlineStyles) { 69 | $this->inlineStyles = []; 70 | } 71 | 72 | $this->inlineStyles[] = $inline; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Add custom CSS properties (CSS vars) to an element. 79 | * Those custom CSS vars will be enqueued with inline style 80 | * to your handle. Variables will be automatically prefixed 81 | * with '--'. 82 | * 83 | * @param string $element 84 | * @param array $vars 85 | * 86 | * @return $this 87 | * 88 | * @example Style::withCssVars('.some-element', ['--white' => '#fff']); 89 | * @example Style::withCssVars('.some-element', ['white' => '#fff']); 90 | */ 91 | public function withCssVars(string $element, array $vars): Style 92 | { 93 | if (!isset($this->cssVars[$element])) { 94 | $this->cssVars[$element] = []; 95 | } 96 | 97 | foreach ($vars as $key => $value) { 98 | $key = substr($key, 0, 2) === '--' 99 | ? $key 100 | : '--' . $key; 101 | 102 | $this->cssVars[$element][$key] = $value; 103 | } 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @return array> 110 | */ 111 | public function cssVars(): array 112 | { 113 | return $this->cssVars; 114 | } 115 | 116 | /** 117 | * @return string 118 | */ 119 | public function cssVarsAsString(): string 120 | { 121 | $return = ''; 122 | foreach ($this->cssVars() as $element => $vars) { 123 | $values = ''; 124 | foreach ($vars as $key => $value) { 125 | $values .= sprintf('%1$s:%2$s;', $key, $value); 126 | } 127 | $return .= sprintf('%1$s{%2$s}', $element, $values); 128 | } 129 | 130 | return $return; 131 | } 132 | 133 | /** 134 | * Wrapper function to set AsyncStyleOutputFilter as filter. 135 | * 136 | * @return static 137 | */ 138 | public function useAsyncFilter(): Style 139 | { 140 | return $this->withFilters(AsyncStyleOutputFilter::class); 141 | } 142 | 143 | /** 144 | * {@inheritDoc} 145 | */ 146 | protected function defaultHandler(): string 147 | { 148 | return StyleHandler::class; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Util/AssetPathResolver.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public const HOOK_TO_LOCATION = [ 34 | Asset::HOOK_FRONTEND => Asset::FRONTEND, 35 | Asset::HOOK_BACKEND => Asset::BACKEND, 36 | Asset::HOOK_LOGIN => Asset::LOGIN, 37 | Asset::HOOK_CUSTOMIZER => Asset::CUSTOMIZER, 38 | Asset::HOOK_CUSTOMIZER_PREVIEW => Asset::CUSTOMIZER_PREVIEW, 39 | Asset::HOOK_BLOCK_ASSETS => Asset::BLOCK_ASSETS, 40 | Asset::HOOK_BLOCK_EDITOR_ASSETS => Asset::BLOCK_EDITOR_ASSETS, 41 | Asset::HOOK_ACTIVATE => Asset::ACTIVATE, 42 | ]; 43 | 44 | /** 45 | * Contains the full url to file. 46 | * 47 | * @return string 48 | */ 49 | public function url(): string; 50 | 51 | /** 52 | * Returns the full file path to the asset. 53 | * 54 | * @return string 55 | */ 56 | public function filePath(): string; 57 | 58 | /** 59 | * Define the full filePath to the Asset. 60 | * 61 | * @param string $filePath 62 | * 63 | * @return static 64 | */ 65 | public function withFilePath(string $filePath): Asset; 66 | 67 | /** 68 | * Name of the given asset. 69 | * 70 | * @return string 71 | */ 72 | public function handle(): string; 73 | 74 | /** 75 | * A list of handle-dependencies. 76 | * 77 | * @return string[] 78 | */ 79 | public function dependencies(): array; 80 | 81 | /** 82 | * @param string ...$dependencies 83 | * 84 | * @return static 85 | */ 86 | public function withDependencies(string ...$dependencies): Asset; 87 | 88 | /** 89 | * The current version of the asset. 90 | * 91 | * @return string|null 92 | */ 93 | public function version(): ?string; 94 | 95 | /** 96 | * @param string $version 97 | * 98 | * @return static 99 | */ 100 | public function withVersion(string $version): Asset; 101 | 102 | /** 103 | * @return bool 104 | */ 105 | public function enqueue(): bool; 106 | 107 | /** 108 | * @param bool|callable $enqueue 109 | * 110 | * @return static 111 | * 112 | * phpcs:disable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 113 | */ 114 | public function canEnqueue($enqueue): Asset; 115 | 116 | /** 117 | * Location where the asset is enqueued. 118 | * 119 | * @return int 120 | * 121 | * @example Asset::FRONTEND | Asset::BACKEND 122 | * @example Asset::FRONTEND 123 | */ 124 | public function location(): int; 125 | 126 | /** 127 | * Define a location based on Asset location types. 128 | * 129 | * @param int $location 130 | * 131 | * @return static 132 | */ 133 | public function forLocation(int $location): Asset; 134 | 135 | /** 136 | * Name of the handler class to register and enqueue the asset. 137 | * 138 | * @return class-string 139 | */ 140 | public function handler(): string; 141 | 142 | /** 143 | * @param class-string $handler 144 | * 145 | * @return static 146 | */ 147 | public function useHandler(string $handler): Asset; 148 | } 149 | -------------------------------------------------------------------------------- /src/Loader/WebpackManifestLoader.php: -------------------------------------------------------------------------------- 1 | $fileOrArray) { 29 | $asset = null; 30 | 31 | if (is_array($fileOrArray)) { 32 | $asset = $this->handleAsArray($handle, $fileOrArray, $directory); 33 | } 34 | if (is_string($fileOrArray)) { 35 | $asset = $this->handleUsingFileName($handle, $fileOrArray, $directory); 36 | } 37 | 38 | if ($asset) { 39 | $assets[] = $asset; 40 | } 41 | } 42 | 43 | return $assets; 44 | } 45 | 46 | /** 47 | * @param Configuration $configuration 48 | * @throws Exception\InvalidArgumentException 49 | * @throws Exception\MissingArgumentException 50 | */ 51 | protected function handleAsArray(string $handle, array $configuration, string $directory): ?Asset 52 | { 53 | $file = $this->extractFilePath($configuration); 54 | 55 | if (!$file) { 56 | return null; 57 | } 58 | 59 | $sanitizedFile = $this->sanitizeFileName($file); 60 | $class = $this->resolveClassByExtension($sanitizedFile); 61 | 62 | if (!$class) { 63 | return null; 64 | } 65 | 66 | $location = $this->buildLocations($configuration); 67 | $version = $this->extractVersion($configuration); 68 | $handle = $this->normalizeHandle($handle); 69 | 70 | $configuration['handle'] = $handle; 71 | $configuration['url'] = $this->fileUrl($sanitizedFile); 72 | $configuration['filePath'] = $this->filePath($sanitizedFile, $directory); 73 | $configuration['type'] = $class; 74 | $configuration['location'] = $location; 75 | $configuration['version'] = $version; 76 | 77 | return AssetFactory::create($configuration); 78 | } 79 | 80 | /** 81 | * @param Configuration $configuration 82 | */ 83 | protected function extractFilePath(array $configuration): ?string 84 | { 85 | $filePath = $configuration['filePath'] ?? null; 86 | return is_string($filePath) ? $filePath : null; 87 | } 88 | 89 | /** 90 | * @param Configuration $configuration 91 | */ 92 | protected function extractVersion(array $configuration): ?string 93 | { 94 | $version = $configuration['version'] ?? null; 95 | 96 | if (!is_string($version)) { 97 | $version = ''; 98 | } 99 | 100 | // Autodiscover version is always true by default for the Webpack Manifest Loader 101 | if ($version) { 102 | $this->enableAutodiscoverVersion(); 103 | } 104 | 105 | return $version; 106 | } 107 | 108 | /** 109 | * @param Configuration $configuration 110 | */ 111 | protected function buildLocations(array $configuration): int 112 | { 113 | $locations = $configuration['location'] ?? null; 114 | $locations = is_array($locations) ? $locations : []; 115 | 116 | if (count($locations) === 0) { 117 | return Asset::FRONTEND; 118 | } 119 | 120 | $locations = array_unique($locations); 121 | $collector = array_shift($locations); 122 | $collector = static::resolveLocation("-{$collector}"); 123 | foreach ($locations as $location) { 124 | $collector |= static::resolveLocation("-{$location}"); 125 | } 126 | 127 | return $collector; 128 | } 129 | 130 | protected function handleUsingFileName(string $handle, string $file, string $directory): ?Asset 131 | { 132 | $handle = $this->normalizeHandle($handle); 133 | $sanitizedFile = $this->sanitizeFileName($file); 134 | $fileUrl = $this->fileUrl($sanitizedFile); 135 | $filePath = $this->filePath($sanitizedFile, $directory); 136 | 137 | return $this->buildAsset($handle, $fileUrl, $filePath); 138 | } 139 | 140 | protected function fileUrl(string $file): string 141 | { 142 | $sanitizedFile = $this->sanitizeFileName($file); 143 | return (!$this->directoryUrl) ? $file : $this->directoryUrl . $sanitizedFile; 144 | } 145 | 146 | protected function filePath(string $file, string $directory): string 147 | { 148 | $sanitizedFile = $this->sanitizeFileName($file); 149 | return untrailingslashit($directory) . '/' . ltrim($sanitizedFile, '/'); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Script.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | protected array $localize = []; 19 | 20 | /** 21 | * @var array{after:string[], before:string[]} 22 | */ 23 | protected array $inlineScripts = [ 24 | 'after' => [], 25 | 'before' => [], 26 | ]; 27 | 28 | protected bool $inFooter = true; 29 | 30 | /** 31 | * @var array{domain:string, path:string|null} 32 | */ 33 | protected array $translation = [ 34 | 'domain' => '', 35 | 'path' => null, 36 | ]; 37 | 38 | /** 39 | * @return array 40 | */ 41 | public function localize(): array 42 | { 43 | $output = []; 44 | foreach ($this->localize as $objectName => $data) { 45 | $output[$objectName] = is_callable($data) 46 | ? $data() 47 | : $data; 48 | } 49 | 50 | return $output; 51 | } 52 | 53 | /** 54 | * @param string $objectName 55 | * @param string|int|array|callable $data 56 | * 57 | * @return static 58 | * 59 | * phpcs:disable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 60 | */ 61 | public function withLocalize(string $objectName, $data): Script 62 | { 63 | // phpcs:enable Inpsyde.CodeQuality.ArgumentTypeDeclaration 64 | 65 | $this->localize[$objectName] = $data; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * @return bool 72 | */ 73 | public function inFooter(): bool 74 | { 75 | return $this->inFooter; 76 | } 77 | 78 | /** 79 | * @return static 80 | */ 81 | public function isInFooter(): Script 82 | { 83 | $this->inFooter = true; 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * @return static 90 | */ 91 | public function isInHeader(): Script 92 | { 93 | $this->inFooter = false; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @return array{before:string[], after:string[]} 100 | */ 101 | public function inlineScripts(): array 102 | { 103 | return $this->inlineScripts; 104 | } 105 | 106 | /** 107 | * @param string $jsCode 108 | * 109 | * @return static 110 | */ 111 | public function prependInlineScript(string $jsCode): Script 112 | { 113 | $this->inlineScripts['before'][] = $jsCode; 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * @param string $jsCode 120 | * 121 | * @return static 122 | */ 123 | public function appendInlineScript(string $jsCode): Script 124 | { 125 | $this->inlineScripts['after'][] = $jsCode; 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * @return array{domain:string, path:string|null} 132 | */ 133 | public function translation(): array 134 | { 135 | return $this->translation; 136 | } 137 | 138 | /** 139 | * @param string $domain 140 | * @param string|null $path 141 | * 142 | * @return static 143 | */ 144 | public function withTranslation(string $domain = 'default', ?string $path = null): Script 145 | { 146 | $this->translation = ['domain' => $domain, 'path' => $path]; 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * Wrapper function to set AsyncScriptOutputFilter as filter. 153 | * 154 | * @return static 155 | * @deprecated use Script::withAttributes(['async' => true]); 156 | */ 157 | public function useAsyncFilter(): Script 158 | { 159 | $this->withAttributes(['async' => true]); 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * Wrapper function to set DeferScriptOutputFilter as filter. 166 | * 167 | * @return static 168 | * @deprecated use Script::withAttributes(['defer' => true]); 169 | */ 170 | public function useDeferFilter(): Script 171 | { 172 | $this->withAttributes(['defer' => true]); 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * {@inheritDoc} 179 | */ 180 | protected function defaultHandler(): string 181 | { 182 | return ScriptHandler::class; 183 | } 184 | 185 | /** 186 | * @deprecated when calling Script::version() or Script::dependencies(), 187 | * we will automatically resolve the dependency extraction plugin files. 188 | * This method will be removed in future. 189 | * 190 | * @see https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin 191 | */ 192 | public function useDependencyExtractionPlugin(): Script 193 | { 194 | return $this; 195 | } 196 | 197 | /** 198 | * {@inheritDoc} 199 | */ 200 | public function version(): ?string 201 | { 202 | $this->resolveDependencyExtractionPlugin(); 203 | 204 | return parent::version(); 205 | } 206 | 207 | /** 208 | * {@inheritDoc} 209 | */ 210 | public function dependencies(): array 211 | { 212 | $this->resolveDependencyExtractionPlugin(); 213 | 214 | return parent::dependencies(); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/BaseAsset.php: -------------------------------------------------------------------------------- 1 | |null 57 | */ 58 | protected $handler = null; 59 | 60 | /** 61 | * @param string $handle 62 | * @param string $url 63 | * @param int $location 64 | */ 65 | public function __construct( 66 | string $handle, 67 | string $url, 68 | int $location = Asset::FRONTEND | Asset::ACTIVATE 69 | ) { 70 | 71 | $this->handle = $handle; 72 | $this->url = $url; 73 | $this->location = $location; 74 | } 75 | 76 | /** 77 | * @return string 78 | */ 79 | public function url(): string 80 | { 81 | return $this->url; 82 | } 83 | 84 | /** 85 | * @return string 86 | */ 87 | public function handle(): string 88 | { 89 | return $this->handle; 90 | } 91 | 92 | /** 93 | * @return string 94 | */ 95 | public function filePath(): string 96 | { 97 | $filePath = $this->filePath; 98 | 99 | if ($filePath !== '') { 100 | return $filePath; 101 | } 102 | 103 | try { 104 | $filePath = AssetPathResolver::resolve($this->url()); 105 | } catch (\Throwable $throwable) { 106 | $filePath = null; 107 | } 108 | 109 | // if replacement fails, don't set the url as path. 110 | if ($filePath === null || !file_exists($filePath)) { 111 | return ''; 112 | } 113 | 114 | $this->withFilePath($filePath); 115 | 116 | return $filePath; 117 | } 118 | 119 | /** 120 | * @param string $filePath 121 | * 122 | * @return static 123 | */ 124 | public function withFilePath(string $filePath): Asset 125 | { 126 | $this->filePath = $filePath; 127 | 128 | return $this; 129 | } 130 | 131 | /** 132 | * Returns a version which will be automatically generated based on file time by default. 133 | * 134 | * @return string|null 135 | */ 136 | public function version(): ?string 137 | { 138 | $version = $this->version; 139 | 140 | if ($version === null && $this->autodiscoverVersion) { 141 | $filePath = $this->filePath(); 142 | $version = (string) filemtime($filePath); 143 | $this->withVersion($version); 144 | 145 | return $version; 146 | } 147 | 148 | return $version === null 149 | ? null 150 | : (string) $version; 151 | } 152 | 153 | /** 154 | * @param string $version 155 | * 156 | * @return static 157 | */ 158 | public function withVersion(string $version): Asset 159 | { 160 | $this->version = $version; 161 | 162 | return $this; 163 | } 164 | 165 | /** 166 | * @return string[] 167 | */ 168 | public function dependencies(): array 169 | { 170 | return array_values(array_unique($this->dependencies)); 171 | } 172 | 173 | /** 174 | * @param string ...$dependencies 175 | * 176 | * @return static 177 | */ 178 | public function withDependencies(string ...$dependencies): Asset 179 | { 180 | $this->dependencies = array_merge( 181 | $this->dependencies, 182 | $dependencies 183 | ); 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * @return int 190 | */ 191 | public function location(): int 192 | { 193 | return (int) $this->location; 194 | } 195 | 196 | /** 197 | * @param int $location 198 | * 199 | * @return static 200 | */ 201 | public function forLocation(int $location): Asset 202 | { 203 | $this->location = $location; 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * @return bool 210 | */ 211 | public function enqueue(): bool 212 | { 213 | $enqueue = $this->enqueue; 214 | is_callable($enqueue) and $enqueue = $enqueue(); 215 | 216 | return (bool) $enqueue; 217 | } 218 | 219 | /** 220 | * @param bool|callable(): bool $enqueue 221 | * 222 | * @return static 223 | * 224 | * phpcs:disable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 225 | * @psalm-suppress MoreSpecificImplementedParamType 226 | */ 227 | public function canEnqueue($enqueue): Asset 228 | { 229 | // phpcs:enable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 230 | 231 | $this->enqueue = $enqueue; 232 | 233 | return $this; 234 | } 235 | 236 | /** 237 | * @param class-string $handler 238 | * 239 | * @return static 240 | */ 241 | public function useHandler(string $handler): Asset 242 | { 243 | $this->handler = $handler; 244 | 245 | return $this; 246 | } 247 | 248 | /** 249 | * @return class-string 250 | */ 251 | public function handler(): string 252 | { 253 | if (!$this->handler) { 254 | $this->handler = $this->defaultHandler(); 255 | } 256 | 257 | return $this->handler; 258 | } 259 | 260 | /** 261 | * @return class-string className of the default handler 262 | */ 263 | abstract protected function defaultHandler(): string; 264 | } 265 | -------------------------------------------------------------------------------- /src/Loader/AbstractWebpackLoader.php: -------------------------------------------------------------------------------- 1 | directoryUrl = $directoryUrl; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * @param array $data 36 | * @param string $resource 37 | * 38 | * @return Asset[] 39 | */ 40 | abstract protected function parseData(array $data, string $resource): array; 41 | 42 | /** 43 | * @param mixed $resource 44 | * 45 | * @return Asset[] 46 | * 47 | * phpcs:disable Syde.Functions.ArgumentTypeDeclaration.NoArgumentType 48 | * @psalm-suppress MixedArgument 49 | */ 50 | public function load($resource): array 51 | { 52 | if (!is_string($resource) || !is_readable($resource)) { 53 | throw new FileNotFoundException( 54 | sprintf( 55 | 'The given file "%s" does not exists or is not readable.', 56 | esc_html($resource) 57 | ) 58 | ); 59 | } 60 | 61 | $data = @file_get_contents($resource) 62 | ?: ''; // phpcs:ignore 63 | $data = json_decode($data, true); 64 | $errorCode = json_last_error(); 65 | if (0 < $errorCode) { 66 | throw new InvalidResourceException( 67 | sprintf( 68 | 'Error parsing JSON - %s', 69 | esc_html($this->getJSONErrorMessage($errorCode)) 70 | ) 71 | ); 72 | } 73 | 74 | return $this->parseData($data, $resource); 75 | } 76 | 77 | /** 78 | * Translates JSON_ERROR_* constant into meaningful message. 79 | * 80 | * @param int $errorCode 81 | * 82 | * @return string Message string 83 | */ 84 | private function getJSONErrorMessage(int $errorCode): string 85 | { 86 | switch ($errorCode) { 87 | case JSON_ERROR_DEPTH: 88 | return 'Maximum stack depth exceeded'; 89 | case JSON_ERROR_STATE_MISMATCH: 90 | return 'Underflow or the modes mismatch'; 91 | case JSON_ERROR_CTRL_CHAR: 92 | return 'Unexpected control character found'; 93 | case JSON_ERROR_SYNTAX: 94 | return 'Syntax error, malformed JSON'; 95 | case JSON_ERROR_UTF8: 96 | return 'Malformed UTF-8 characters, possibly incorrectly encoded'; 97 | default: 98 | return 'Unknown error'; 99 | } 100 | } 101 | 102 | /** 103 | * @param string $handle 104 | * @param string $fileUrl 105 | * @param string $filePath 106 | * 107 | * @return Asset|null 108 | */ 109 | protected function buildAsset(string $handle, string $fileUrl, string $filePath): ?Asset 110 | { 111 | /** @var array{filename?:string, extension?:string} $pathInfo */ 112 | $pathInfo = pathinfo($filePath); 113 | $filename = $pathInfo['filename'] ?? ''; 114 | 115 | $class = $this->resolveClassByExtension($filePath); 116 | 117 | if (!$class) { 118 | return null; 119 | } 120 | 121 | /** @var Style|Script|ScriptModule $asset */ 122 | $asset = new $class($handle, $fileUrl, $this->resolveLocation($filename)); 123 | $asset->withFilePath($filePath); 124 | $asset->canEnqueue(true); 125 | 126 | if ($asset instanceof BaseAsset) { 127 | $this->autodiscoverVersion 128 | ? $asset->enableAutodiscoverVersion() 129 | : $asset->disableAutodiscoverVersion(); 130 | } 131 | 132 | return $asset; 133 | } 134 | 135 | protected function resolveClassByExtension(string $filePath): ?string 136 | { 137 | $extensionsToClass = [ 138 | 'css' => Style::class, 139 | 'js' => Script::class, 140 | 'mjs' => ScriptModule::class, 141 | 'module.js' => ScriptModule::class, 142 | ]; 143 | 144 | // TODO Maybe make use of \SplFileInfo since it's typed and we can share it 145 | // we have to just make a factory method and that's it. 146 | /** @var array{filename?:string, extension?:string} $pathInfo */ 147 | $pathInfo = pathinfo($filePath); 148 | $baseName = $pathInfo['basename'] ?? ''; 149 | $extension = $pathInfo['extension'] ?? ''; 150 | 151 | if (self::isModule($baseName)) { 152 | $extension = 'module.js'; 153 | } 154 | 155 | if (!in_array($extension, array_keys($extensionsToClass), true)) { 156 | return null; 157 | } 158 | 159 | return $extensionsToClass[$extension]; 160 | } 161 | 162 | protected static function isModule(string $fileName): bool 163 | { 164 | // TODO replace it with `str_ends_with` once dropping support for php 7.4 165 | $strEndsWith = static function (string $haystack, string $needle): bool { 166 | return substr_compare($haystack, $needle, -strlen($needle)) === 0; 167 | }; 168 | return $strEndsWith($fileName, '.module.js') || $strEndsWith($fileName, '.mjs'); 169 | } 170 | 171 | /** 172 | * The "file"-value can contain: 173 | * - URL 174 | * - Path to current folder 175 | * - Absolute path 176 | * 177 | * We try to build a clean path which will be appended to the directoryPath or urlPath. 178 | * 179 | * @param string $file 180 | * 181 | * @return string 182 | */ 183 | protected function sanitizeFileName(string $file): string 184 | { 185 | // Check if the given "file"-value is a URL 186 | $parsedUrl = parse_url($file); 187 | 188 | // the "file"-value can contain "./file.css" or "/file.css". 189 | 190 | return ltrim($parsedUrl['path'] ?? $file, './'); 191 | } 192 | 193 | /** 194 | * Internal function to sanitize the handle based on the file 195 | * by taking into consideration that @vendor can be present. 196 | * 197 | * @param string $file 198 | * 199 | * @return string 200 | * @example /path/to/@vendor/script.module.js -> @vendor/script.module 201 | * 202 | * @example /path/to/script.js -> script 203 | * @example @vendor/script.module.js -> @vendor/script.module 204 | */ 205 | protected function normalizeHandle(string $file): string 206 | { 207 | $pathInfo = pathinfo($file); 208 | 209 | $dirName = $pathInfo['dirname'] ?? ''; 210 | $parts = explode('@', $dirName); 211 | $vendor = $parts[1] ?? null; 212 | 213 | $handle = $pathInfo['filename']; 214 | if ($vendor !== null) { 215 | $handle = "@{$vendor}/{$handle}"; 216 | } 217 | 218 | return $handle; 219 | } 220 | 221 | /** 222 | * Internal function to resolve a location for a given file name. 223 | * 224 | * @param string $fileName 225 | * 226 | * @return int 227 | * 228 | * @example foo-customizer.css -> Asset::CUSTOMIZER 229 | * @example foo-block.css -> Asset::BLOCK_EDITOR_ASSETS 230 | * @example foo-login.css -> Asset::LOGIN 231 | * @example foo.css -> Asset::FRONTEND 232 | * @example foo-backend.css -> Asset::BACKEND 233 | */ 234 | protected function resolveLocation(string $fileName): int 235 | { 236 | if (stristr($fileName, '-backend')) { 237 | return Asset::BACKEND; 238 | } 239 | 240 | if (stristr($fileName, '-block')) { 241 | return Asset::BLOCK_EDITOR_ASSETS; 242 | } 243 | 244 | if (stristr($fileName, '-login')) { 245 | return Asset::LOGIN; 246 | } 247 | 248 | if (stristr($fileName, '-customizer-preview')) { 249 | return Asset::CUSTOMIZER_PREVIEW; 250 | } 251 | 252 | if (stristr($fileName, '-customizer')) { 253 | return Asset::CUSTOMIZER; 254 | } 255 | 256 | return Asset::FRONTEND; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/AssetManager.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private array $hooksAdded = []; 28 | 29 | /** 30 | * @var array< 31 | * Style::class|Script::class|ScriptModule::class, 32 | * array 33 | * > 34 | */ 35 | private array $extensions = []; 36 | 37 | /** 38 | * @var array> 39 | */ 40 | private array $processedAssets = []; 41 | 42 | private AssetCollection $assets; 43 | 44 | /** 45 | * @var array 46 | */ 47 | private array $handlers = []; 48 | 49 | private AssetHookResolver $hookResolver; 50 | 51 | private bool $setupDone = false; 52 | 53 | /** 54 | * @param AssetHookResolver|null $hookResolver 55 | */ 56 | public function __construct(?AssetHookResolver $hookResolver = null) 57 | { 58 | $this->hookResolver = $hookResolver ?? new AssetHookResolver(); 59 | $this->assets = new AssetCollection(); 60 | } 61 | 62 | /** 63 | * @return static 64 | */ 65 | public function useDefaultHandlers(): AssetManager 66 | { 67 | $this->handlers[StyleHandler::class] ??= new StyleHandler(wp_styles()); 68 | $this->handlers[ScriptHandler::class] ??= new ScriptHandler(wp_scripts()); 69 | $this->handlers[ScriptModuleHandler::class] ??= new ScriptModuleHandler(); 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @param string $name 76 | * @param AssetHandler $handler 77 | * 78 | * @return static 79 | */ 80 | public function withHandler(string $name, AssetHandler $handler): AssetManager 81 | { 82 | $this->handlers[$name] = $handler; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function handlers(): array 91 | { 92 | return $this->handlers; 93 | } 94 | 95 | /** 96 | * @param Asset $asset 97 | * @param Asset ...$assets 98 | * 99 | * @return static 100 | */ 101 | public function register(Asset $asset, Asset ...$assets): AssetManager 102 | { 103 | array_unshift($assets, $asset); 104 | 105 | foreach ($assets as $asset) { 106 | $this->extendAndRegisterAsset($asset); 107 | } 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * @return array> 114 | */ 115 | public function assets(): array 116 | { 117 | $this->ensureSetup(); 118 | 119 | return $this->assets->all(); 120 | } 121 | 122 | /** 123 | * Retrieve an `Asset` instance by a given asset handle and type (class). 124 | * 125 | * @param string $handle 126 | * @param class-string|null $type Deprecated, will be in future not nullable. 127 | * 128 | * @return Asset|null 129 | */ 130 | public function asset(string $handle, ?string $type = null): ?Asset 131 | { 132 | $this->ensureSetup(); 133 | 134 | if ($type === null) { 135 | return $this->assets->getFirst($handle); 136 | } 137 | 138 | return $this->assets->get($handle, $type); 139 | } 140 | 141 | /** 142 | * @param string $handle 143 | * @param string $type 144 | * @param AssetExtensionConfig $extensions 145 | * 146 | * @return $this 147 | */ 148 | public function extendAsset(string $handle, string $type, array $extensions): AssetManager 149 | { 150 | $existingExtension = $this->extensions[$type][$handle] ?? []; 151 | $extensions = array_merge_recursive($existingExtension, $extensions); 152 | $this->extensions[$type][$handle] = $extensions; 153 | 154 | // In case, the asset is already registered, 155 | // but not yet processed, extend it. 156 | $asset = $this->assets->get($handle, $type); 157 | if ($asset !== null && !$this->isAssetProcessed($asset)) { 158 | $this->extendAndRegisterAsset($asset); 159 | } 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * @param string $handle 166 | * @param string $type 167 | * 168 | * @return AssetExtensionConfig 169 | */ 170 | public function assetExtensions(string $handle, string $type): array 171 | { 172 | return $this->extensions[$type][$handle] ?? []; 173 | } 174 | 175 | /** 176 | * @param Asset $asset 177 | * 178 | * @return $this 179 | */ 180 | protected function extendAndRegisterAsset(Asset $asset): AssetManager 181 | { 182 | $handle = $asset->handle(); 183 | $type = get_class($asset); 184 | $extensions = $this->assetExtensions($handle, $type); 185 | if (count($extensions) > 0 && !$this->isAssetProcessed($asset)) { 186 | $asset = AssetFactory::configureAsset($asset, $extensions); 187 | } 188 | 189 | $this->assets->add($asset); 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * @return bool 196 | */ 197 | public function setup(): bool 198 | { 199 | $hooksAdded = 0; 200 | 201 | /** 202 | * It is possible to execute AssetManager::setup() at a specific hook to only process assets 203 | * specific of that hook. 204 | * 205 | * E.g. `add_action('enqueue_block_editor_assets', [new AssetManager, 'setup']);` 206 | * 207 | * `$this->hookResolver->resolve()` will return current hook if it is one of the assets 208 | * enqueuing hook. 209 | */ 210 | foreach ($this->hookResolver->resolve() as $hook) { 211 | // If the hook was already added, or it is in the past, don't bother adding. 212 | if (!empty($this->hooksAdded[$hook]) || (did_action($hook) && !doing_action($hook))) { 213 | continue; 214 | } 215 | 216 | $hooksAdded++; 217 | $this->hooksAdded[$hook] = true; 218 | 219 | add_action( 220 | $hook, 221 | function () use ($hook) { 222 | $this->processAssets($hook); 223 | } 224 | ); 225 | } 226 | 227 | return $hooksAdded > 0; 228 | } 229 | 230 | /** 231 | * Returning all matching assets to given hook. 232 | * 233 | * @param string $currentHook 234 | * 235 | * @return array 236 | */ 237 | public function currentAssets(string $currentHook): array 238 | { 239 | return $this->loopCurrentHookAssets($currentHook, false); 240 | } 241 | 242 | /** 243 | * @param string $currentHook 244 | * 245 | * @return void 246 | */ 247 | private function processAssets(string $currentHook): void 248 | { 249 | $this->loopCurrentHookAssets($currentHook, true); 250 | } 251 | 252 | /** 253 | * @param string $currentHook 254 | * @param bool $process 255 | * 256 | * @return array 257 | * 258 | * phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh 259 | */ 260 | private function loopCurrentHookAssets(string $currentHook, bool $process): array 261 | { 262 | $this->ensureSetup(); 263 | if (count($this->assets->all()) < 1) { 264 | return []; 265 | } 266 | 267 | /** @var int|null $locationId */ 268 | $locationId = Asset::HOOK_TO_LOCATION[$currentHook] ?? null; 269 | if (!$locationId) { 270 | return []; 271 | } 272 | 273 | $found = []; 274 | 275 | foreach ($this->assets->all() as $type => $assets) { 276 | foreach ($assets as $asset) { 277 | $handlerName = $asset->handler(); 278 | $handler = $this->handlers[$handlerName] ?? null; 279 | if (!$handler) { 280 | continue; 281 | } 282 | 283 | $location = $asset->location(); 284 | if (($location & $locationId) !== $locationId) { 285 | continue; 286 | } 287 | 288 | $found[] = $asset; 289 | if (!$process) { 290 | continue; 291 | } 292 | 293 | $done = $asset->enqueue() 294 | ? $handler->enqueue($asset) 295 | : $handler->register($asset); 296 | if ($done && ($handler instanceof OutputFilterAwareAssetHandler)) { 297 | $handler->filter($asset); 298 | } 299 | 300 | $this->processedAssets[$type . '_' . $handlerName] = $done; 301 | } 302 | } 303 | 304 | return $found; 305 | } 306 | 307 | protected function isAssetProcessed(Asset $asset): bool 308 | { 309 | return (bool) ($this->processedAssets[get_class($asset) . '_' . $asset->handle()] ?? false); 310 | } 311 | 312 | /** 313 | * @return void 314 | */ 315 | private function ensureSetup(): void 316 | { 317 | if ($this->setupDone) { 318 | return; 319 | } 320 | 321 | $this->setupDone = true; 322 | 323 | $lastHook = $this->hookResolver->lastHook(); 324 | 325 | /** 326 | * We should not setup if there's no hook or last hook already fired. 327 | * 328 | * @psalm-suppress PossiblyNullArgument 329 | */ 330 | if (!$lastHook && did_action($lastHook) && !doing_action($lastHook)) { 331 | $this->assets = new AssetCollection(); 332 | 333 | return; 334 | } 335 | 336 | $this->useDefaultHandlers(); 337 | do_action(self::ACTION_SETUP, $this); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/AssetFactory.php: -------------------------------------------------------------------------------- 1 | |class-string