├── 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