* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\CMS\WebAsset; use Joomla\CMS\Event\WebAsset\WebAssetRegistryAssetChanged; use Joomla\CMS\WebAsset\Exception\InvalidActionException; use Joomla\CMS\WebAsset\Exception\UnknownAssetException; use Joomla\CMS\WebAsset\Exception\UnsatisfiedDependencyException; // phpcs:disable PSR1.Files.SideEffects \defined('JPATH_PLATFORM') or die; // phpcs:enable PSR1.Files.SideEffects /** * Web Asset Manager class * * @method WebAssetManager registerStyle(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager registerAndUseStyle(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager useStyle($name) * @method WebAssetManager disableStyle($name) * @method WebAssetManager addInlineStyle(WebAssetItem|string $content, $options = [], $attributes = [], $dependencies = []) * * @method WebAssetManager registerScript(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager registerAndUseScript(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager useScript($name) * @method WebAssetManager disableScript($name) * @method WebAssetManager addInlineScript(WebAssetItem|string $content, $options = [], $attributes = [], $dependencies = []) * * @method WebAssetManager registerPreset(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager registerAndUsePreset(WebAssetItem|string $asset, string $uri = '', $options = [], $attributes = [], $dependencies = []) * @method WebAssetManager usePreset($name) * @method WebAssetManager disablePreset($name) * * @since 4.0.0 */ class WebAssetManager implements WebAssetManagerInterface { /** * Mark inactive asset * * @var integer * * @since 4.0.0 */ public const ASSET_STATE_INACTIVE = 0; /** * Mark active asset. Just enabled, but WITHOUT dependency resolved * * @var integer * * @since 4.0.0 */ public const ASSET_STATE_ACTIVE = 1; /** * Mark active asset that is enabled as dependency to another asset * * @var integer * * @since 4.0.0 */ public const ASSET_STATE_DEPENDENCY = 2; /** * The WebAsset Registry instance * * @var WebAssetRegistry * * @since 4.0.0 */ protected $registry; /** * A list of active assets (including their dependencies). * Array of Name => State * * @var array * * @since 4.0.0 */ protected $activeAssets = []; /** * Internal marker to check the manager state, * to prevent use of the manager after an assets are rendered * * @var boolean * * @since 4.0.0 */ protected $locked = false; /** * Internal marker to keep track when need to recheck dependencies * * @var boolean * * @since 4.0.0 */ protected $dependenciesIsActual = false; /** * Class constructor * * @param WebAssetRegistry $registry The WebAsset Registry instance * * @since 4.0.0 */ public function __construct(WebAssetRegistry $registry) { $this->registry = $registry; // Listen to changes in the registry $this->registry->getDispatcher()->addListener( 'onWebAssetRegistryChangedAssetOverride', function (WebAssetRegistryAssetChanged $event) { // If the changed asset are used if (!empty($this->activeAssets[$event->getAssetType()][$event->getAsset()->getName()])) { $this->dependenciesIsActual = false; } } ); $this->registry->getDispatcher()->addListener( 'onWebAssetRegistryChangedAssetRemove', function (WebAssetRegistryAssetChanged $event) { // If the changed asset are used if (!empty($this->activeAssets[$event->getAssetType()][$event->getAsset()->getName()])) { $this->dependenciesIsActual = false; } } ); } /** * Get associated registry instance * * @return WebAssetRegistry * * @since 4.0.0 */ public function getRegistry(): WebAssetRegistry { return $this->registry; } /** * Clears all collected items. * * @return self * * @since 4.1.1 */ public function reset(): WebAssetManagerInterface { if ($this->locked) { throw new InvalidActionException('WebAssetManager is locked'); } $this->activeAssets = []; $this->dependenciesIsActual = false; return $this; } /** * Adds support for magic method calls * * @param string $method A method name * @param array $arguments Arguments for a method * * @return mixed * * @throws \BadMethodCallException * * @since 4.0.0 */ public function __call($method, $arguments) { $method = strtolower($method); if (0 === strpos($method, 'use')) { $type = substr($method, 3); if (empty($arguments[0])) { throw new \BadMethodCallException('An asset name is required'); } return $this->useAsset($type, $arguments[0]); } if (0 === strpos($method, 'addinline')) { $type = substr($method, 9); if (empty($arguments[0])) { throw new \BadMethodCallException('Content is required'); } return $this->addInline($type, ...$arguments); } if (0 === strpos($method, 'disable')) { $type = substr($method, 7); if (empty($arguments[0])) { throw new \BadMethodCallException('An asset name is required'); } return $this->disableAsset($type, $arguments[0]); } if (0 === strpos($method, 'register')) { // Check for registerAndUse $andUse = substr($method, 8, 6) === 'anduse'; // Extract the type $type = $andUse ? substr($method, 14) : substr($method, 8); if (empty($arguments[0])) { throw new \BadMethodCallException('An asset instance or an asset name is required'); } if ($andUse) { $name = $arguments[0] instanceof WebAssetItemInterface ? $arguments[0]->getName() : $arguments[0]; return $this->registerAsset($type, ...$arguments)->useAsset($type, $name); } else { return $this->registerAsset($type, ...$arguments); } } throw new \BadMethodCallException(sprintf('Undefined method %s in class %s', $method, get_class($this))); } /** * Enable an asset item to be attached to a Document * * @param string $type The asset type, script or style * @param string $name The asset name * * @return self * * @throws UnknownAssetException When Asset cannot be found * @throws InvalidActionException When the Manager already attached to a Document * * @since 4.0.0 */ public function useAsset(string $type, string $name): WebAssetManagerInterface { if ($this->locked) { throw new InvalidActionException('WebAssetManager is locked, you came late'); } // Check whether asset exists $asset = $this->registry->get($type, $name); if (empty($this->activeAssets[$type])) { $this->activeAssets[$type] = []; } // For "preset" need to check the dependencies first if ($type === 'preset') { $this->usePresetItems($name); } // Asset already enabled if (!empty($this->activeAssets[$type][$name])) { // Set state to active, in case it was ASSET_STATE_DEPENDENCY $this->activeAssets[$type][$name] = static::ASSET_STATE_ACTIVE; return $this; } $this->activeAssets[$type][$name] = static::ASSET_STATE_ACTIVE; // To re-check dependencies if ($asset->getDependencies()) { $this->dependenciesIsActual = false; } return $this; } /** * Deactivate an asset item, so it will not be attached to a Document * * @param string $type The asset type, script or style * @param string $name The asset name * * @return self * * @throws UnknownAssetException When Asset cannot be found * @throws InvalidActionException When the Manager already attached to a Document * * @since 4.0.0 */ public function disableAsset(string $type, string $name): WebAssetManagerInterface { if ($this->locked) { throw new InvalidActionException('WebAssetManager is locked, you came late'); } // Check whether asset exists $this->registry->get($type, $name); unset($this->activeAssets[$type][$name]); // To re-check dependencies $this->dependenciesIsActual = false; // For Preset case if ($type === 'preset') { $this->disablePresetItems($name); } return $this; } /** * Enable list of assets provided by Preset item. * * "Preset" a special kind of asset that hold a list of assets that has to be enabled, * same as direct call of useAsset() to each of item in list. * Can hold mixed types of assets (script, style, another preset, etc), the type provided after # symbol, after * the asset name, example: foo#style, bar#script. * * The method call useAsset() internally for each of its dependency, this is important for keeping FIFO order * of enabled items. * The Preset not a strict asset, and each of its dependency can be safely disabled by use of disableAsset() later. * * @param string $name The asset name * * @return self * * @throws UnsatisfiedDependencyException When Asset dependency cannot be found * * @since 4.0.0 */ protected function usePresetItems($name): WebAssetManagerInterface { // Get the asset object $asset = $this->registry->get('preset', $name); // Call useAsset() to each of its dependency foreach ($asset->getDependencies() as $dependency) { $depType = ''; $depName = $dependency; $pos = strrpos($dependency, '#'); // Check for cross-dependency "dependency-name#type" case if ($pos) { $depType = substr($dependency, $pos + 1); $depName = substr($dependency, 0, $pos); } $depType = $depType ?: 'preset'; // Make sure dependency exists if (!$this->registry->exists($depType, $depName)) { throw new UnsatisfiedDependencyException( sprintf('Unsatisfied dependency "%s" for an asset "%s" of type "%s"', $dependency, $name, 'preset') ); } $this->useAsset($depType, $depName); } return $this; } /** * Deactivate list of assets provided by Preset item. * * @param string $name The asset name * * @return self * * @throws UnsatisfiedDependencyException When Asset dependency cannot be found * * @since 4.0.0 */ protected function disablePresetItems($name): WebAssetManagerInterface { // Get the asset object $asset = $this->registry->get('preset', $name); // Call disableAsset() to each of its dependency foreach ($asset->getDependencies() as $dependency) { $depType = ''; $depName = $dependency; $pos = strrpos($dependency, '#'); // Check for cross-dependency "dependency-name#type" case if ($pos) { $depType = substr($dependency, $pos + 1); $depName = substr($dependency, 0, $pos); } $depType = $depType ?: 'preset'; // Make sure dependency exists if (!$this->registry->exists($depType, $depName)) { throw new UnsatisfiedDependencyException( sprintf('Unsatisfied dependency "%s" for an asset "%s" of type "%s"', $dependency, $name, 'preset') ); } $this->disableAsset($depType, $depName); } return $this; } /** * Get a state for the Asset * * @param string $type The asset type, script or style * @param string $name The asset name * * @return integer * * @throws UnknownAssetException When Asset cannot be found * * @since 4.0.0 */ public function getAssetState(string $type, string $name): int { // Check whether asset exists first $this->registry->get($type, $name); // Make sure that all dependencies are active if (!$this->dependenciesIsActual) { $this->enableDependencies(); } if (!empty($this->activeAssets[$type][$name])) { return $this->activeAssets[$type][$name]; } return static::ASSET_STATE_INACTIVE; } /** * Check whether the asset are enabled * * @param string $type The asset type, script or style * @param string $name The asset name * * @return boolean * * @throws UnknownAssetException When Asset cannot be found * * @since 4.0.0 */ public function isAssetActive(string $type, string $name): bool { return $this->getAssetState($type, $name) !== static::ASSET_STATE_INACTIVE; } /** * Helper method to check whether the asset exists in the registry. * * @param string $type Asset type, script or style * @param string $name Asset name * * @return boolean * * @since 4.0.0 */ public function assetExists(string $type, string $name): bool { return $this->registry->exists($type, $name); } /** * Register a new asset. * Allow to register WebAssetItem instance in the registry, by call registerAsset($type, $assetInstance) * Or create an asset on fly (from name and Uri) and register in the registry, by call registerAsset($type, $assetName, $uri, $options ....) * * @param string $type The asset type, script or style * @param WebAssetItem|string $asset The asset name or instance to register * @param string $uri The URI for the asset * @param array $options Additional options for the asset * @param array $attributes Attributes for the asset * @param array $dependencies Asset dependencies * * @return self * * @since 4.0.0 * * @throws \InvalidArgumentException */ public function registerAsset(string $type, $asset, string $uri = '', array $options = [], array $attributes = [], array $dependencies = []) { if ($asset instanceof WebAssetItemInterface) { $this->registry->add($type, $asset); } elseif (is_string($asset)) { $options['type'] = $type; $assetInstance = $this->registry->createAsset($asset, $uri, $options, $attributes, $dependencies); $this->registry->add($type, $assetInstance); } else { throw new \InvalidArgumentException( sprintf( '%s(): Argument #2 ($asset) must be a string or an instance of %s, %s given.', __METHOD__, WebAssetItemInterface::class, \is_object($asset) ? \get_class($asset) : \gettype($asset) ) ); } return $this; } /** * Helper method to get the asset from the registry. * * @param string $type Asset type, script or style * @param string $name Asset name * * @return WebAssetItemInterface * * @throws UnknownAssetException When Asset cannot be found * * @since 4.0.0 */ public function getAsset(string $type, string $name): WebAssetItemInterface { return $this->registry->get($type, $name); } /** * Get all active assets, optionally sort them to follow the dependency Graph * * @param string $type The asset type, script or style * @param bool $sort Whether need to sort the assets to follow the dependency Graph * * @return WebAssetItem[] * * @throws UnknownAssetException When Asset cannot be found * @throws UnsatisfiedDependencyException When Dependency cannot be found * * @since 4.0.0 */ public function getAssets(string $type, bool $sort = false): array { // Make sure that all dependencies are active if (!$this->dependenciesIsActual) { $this->enableDependencies(); } if (empty($this->activeAssets[$type])) { return []; } // Apply Tree sorting for regular asset items, but return FIFO order for "preset" if ($sort && $type !== 'preset') { $assets = $this->calculateOrderOfActiveAssets($type); } else { $assets = []; foreach (array_keys($this->activeAssets[$type]) as $name) { $assets[$name] = $this->registry->get($type, $name); } } return $assets; } /** * Helper method to calculate inline to non inline relation (before/after positions). * Return associated array, which contain dependency (handle) name as key, and list of inline items for each position. * Example: ['handle.name' => ['before' => ['inline1', 'inline2'], 'after' => ['inline3', 'inline4']]] * * Note: If inline asset have a multiple dependencies, then will be used last one from the list for positioning * * @param WebAssetItem[] $assets The assets list * * @return array * * @since 4.0.0 */ public function getInlineRelation(array $assets): array { $inlineRelation = []; // Find an inline assets and their relations to non inline foreach ($assets as $k => $asset) { if (!$asset->getOption('inline')) { continue; } // Add to list of inline assets $inlineAssets[$asset->getName()] = $asset; // Check whether position are requested with dependencies $position = $asset->getOption('position'); $position = $position === 'before' || $position === 'after' ? $position : null; $deps = $asset->getDependencies(); if ($position && $deps) { // If inline asset have a multiple dependencies, then use last one from the list for positioning $handle = end($deps); $inlineRelation[$handle][$position][$asset->getName()] = $asset; } } return $inlineRelation; } /** * Helper method to filter an inline assets * * @param WebAssetItem[] $assets Reference to a full list of active assets * * @return WebAssetItem[] Array of inline assets * * @since 4.0.0 */ public function filterOutInlineAssets(array &$assets): array { $inlineAssets = []; foreach ($assets as $k => $asset) { if (!$asset->getOption('inline')) { continue; } // Remove inline assets from assets list, and add to list of inline unset($assets[$k]); $inlineAssets[$asset->getName()] = $asset; } return $inlineAssets; } /** * Add a new inline content asset. * Allow to register WebAssetItem instance in the registry, by call addInline($type, $assetInstance) * Or create an asset on fly (from name and Uri) and register in the registry, by call addInline($type, $content, $options ....) * * @param string $type The asset type, script or style * @param WebAssetItem|string $content The content to of inline asset * @param array $options Additional options for the asset * @param array $attributes Attributes for the asset * @param array $dependencies Asset dependencies * * @return self * * @since 4.0.0 * * @throws \InvalidArgumentException */ public function addInline(string $type, $content, array $options = [], array $attributes = [], array $dependencies = []): self { if ($content instanceof WebAssetItemInterface) { $assetInstance = $content; } elseif (is_string($content)) { $name = $options['name'] ?? ('inline.' . md5($content)); $assetInstance = $this->registry->createAsset($name, '', $options, $attributes, $dependencies); $assetInstance->setOption('content', $content); } else { throw new \InvalidArgumentException( sprintf( '%s(): Argument #2 ($content) must be a string or an instance of %s, %s given.', __METHOD__, WebAssetItemInterface::class, \is_object($content) ? \get_class($content) : \gettype($content) ) ); } // Get the name $asset = $assetInstance->getName(); // Set required options $assetInstance->setOption('type', $type); $assetInstance->setOption('inline', true); // Add to registry $this->registry->add($type, $assetInstance); // And make active $this->useAsset($type, $asset); return $this; } /** * Lock the manager to prevent further modifications * * @return self * * @since 4.0.0 */ public function lock(): self { $this->locked = true; return $this; } /** * Get the manager state. A collection of registry files and active asset names (per type). * * @return array * * @since 4.0.0 */ public function getManagerState(): array { return [ 'registryFiles' => $this->getRegistry()->getRegistryFiles(), 'activeAssets' => $this->activeAssets, ]; } /** * Update Dependencies state for all active Assets or only for given * * @param string $type The asset type, script or style * @param WebAssetItem $asset The asset instance to which need to enable dependencies * * @return self * * @since 4.0.0 */ protected function enableDependencies(string $type = null, WebAssetItem $asset = null): self { if ($type === 'preset') { // Preset items already was enabled by usePresetItems() return $this; } if ($asset) { // Get all dependencies of given asset recursively $allDependencies = $this->getDependenciesForAsset($type, $asset, true); foreach ($allDependencies as $depType => $depItems) { foreach ($depItems as $depItem) { // Set dependency state only when it is inactive, to keep a manually activated Asset in their original state if (empty($this->activeAssets[$depType][$depItem->getName()])) { // Add the dependency at the top of the list of active assets $this->activeAssets[$depType] = [$depItem->getName() => static::ASSET_STATE_DEPENDENCY] + $this->activeAssets[$depType]; } } } } else { // Re-Check for dependencies for all active assets // Firstly, filter out only active assets foreach ($this->activeAssets as $type => $activeAsset) { $this->activeAssets[$type] = array_filter( $activeAsset, function ($state) { return $state === WebAssetManager::ASSET_STATE_ACTIVE; } ); } // Secondary, check for dependencies of each active asset // This need to be separated from previous step because we may have "cross type" dependency foreach ($this->activeAssets as $type => $activeAsset) { foreach (array_keys($activeAsset) as $name) { $asset = $this->registry->get($type, $name); $this->enableDependencies($type, $asset); } } $this->dependenciesIsActual = true; } return $this; } /** * Calculate weight of active Assets, by its Dependencies * * @param string $type The asset type, script or style * * @return WebAssetItem[] * * @since 4.0.0 */ protected function calculateOrderOfActiveAssets($type): array { // See https://en.wikipedia.org/wiki/Topological_sorting#Kahn.27s_algorithm $graphOrder = []; $activeAssets = $this->getAssets($type, false); // Get Graph of Outgoing and Incoming connections $connectionsGraph = $this->getConnectionsGraph($activeAssets); $graphOutgoing = $connectionsGraph['outgoing']; $graphIncoming = $connectionsGraph['incoming']; // Make a copy to be used during weight processing $graphIncomingCopy = $graphIncoming; // Find items without incoming connections $emptyIncoming = array_keys( array_filter( $graphIncoming, function ($el) { return !$el; } ) ); // Reverse, to start from a last enabled and move up to a first enabled, this helps to maintain an original sorting $emptyIncoming = array_reverse($emptyIncoming); // Loop through, and sort the graph while ($emptyIncoming) { // Add the node without incoming connection to the result $item = array_shift($emptyIncoming); $graphOrder[] = $item; // Check of each neighbor of the node foreach (array_reverse($graphOutgoing[$item]) as $neighbor) { // Remove incoming connection of already visited node unset($graphIncoming[$neighbor][$item]); // If there no more incoming connections add the node to queue if (empty($graphIncoming[$neighbor])) { $emptyIncoming[] = $neighbor; } } } // Sync Graph order with FIFO order $fifoWeights = []; $graphWeights = []; $requestedWeights = []; foreach (array_keys($this->activeAssets[$type]) as $index => $name) { $fifoWeights[$name] = $index * 10 + 10; } foreach (array_reverse($graphOrder) as $index => $name) { $graphWeights[$name] = $index * 10 + 10; $requestedWeights[$name] = $activeAssets[$name]->getOption('weight') ?: $fifoWeights[$name]; } // Try to set a requested weight, or make it close as possible to requested, but keep the Graph order while ($requestedWeights) { $item = key($requestedWeights); $weight = array_shift($requestedWeights); // Skip empty items if ($weight === null) { continue; } // Check the predecessors (Outgoing vertexes), the weight cannot be lighter than the predecessor have $topBorder = $weight - 1; if (!empty($graphOutgoing[$item])) { $prevWeights = []; foreach ($graphOutgoing[$item] as $pItem) { $prevWeights[] = $graphWeights[$pItem]; } $topBorder = max($prevWeights); } // Calculate a new weight $newWeight = $weight > $topBorder ? $weight : $topBorder + 1; // If a new weight heavier than existing, then we need to update all incoming connections (children) if ($newWeight > $graphWeights[$item] && !empty($graphIncomingCopy[$item])) { // Sort Graph of incoming by actual position foreach ($graphIncomingCopy[$item] as $incomingItem) { // Set a weight heavier than current, then this node to be processed in next iteration if (empty($requestedWeights[$incomingItem])) { $requestedWeights[$incomingItem] = $graphWeights[$incomingItem] + $newWeight; } } } // Set a new weight $graphWeights[$item] = $newWeight; } asort($graphWeights); // Get Assets in calculated order $resultAssets = []; foreach (array_keys($graphWeights) as $name) { $resultAssets[$name] = $activeAssets[$name]; } return $resultAssets; } /** * Build Graph of Outgoing and Incoming connections for given assets. * * @param WebAssetItem[] $assets Asset instances * * @return array * * @since 4.0.0 */ protected function getConnectionsGraph(array $assets): array { $graphOutgoing = []; $graphIncoming = []; foreach ($assets as $asset) { $name = $asset->getName(); // Initialise an array for outgoing nodes of the asset $graphOutgoing[$name] = []; // Initialise an array for incoming nodes of the asset if (!\array_key_exists($name, $graphIncoming)) { $graphIncoming[$name] = []; } // Collect an outgoing/incoming nodes foreach ($asset->getDependencies() as $depName) { $graphOutgoing[$name][$depName] = $depName; $graphIncoming[$depName][$name] = $name; } } return [ 'outgoing' => $graphOutgoing, 'incoming' => $graphIncoming, ]; } /** * Return dependencies for Asset as array of WebAssetItem objects * * @param string $type The asset type, script or style * @param WebAssetItem $asset Asset instance * @param boolean $recursively Whether to search for dependency recursively * @param string $recursionType The type of initial item to prevent loop * @param WebAssetItem $recursionRoot Initial item to prevent loop * * @return array * * @throws UnsatisfiedDependencyException When Dependency cannot be found * * @since 4.0.0 */ protected function getDependenciesForAsset( string $type, WebAssetItem $asset, $recursively = false, string $recursionType = null, WebAssetItem $recursionRoot = null ): array { $assets = []; $recursionRoot = $recursionRoot ?? $asset; $recursionType = $recursionType ?? $type; foreach ($asset->getDependencies() as $depName) { $depType = $type; // Skip already loaded in recursion if ($recursionRoot->getName() === $depName && $recursionType === $depType) { continue; } if (!$this->registry->exists($depType, $depName)) { throw new UnsatisfiedDependencyException( sprintf('Unsatisfied dependency "%s" for an asset "%s" of type "%s"', $depName, $asset->getName(), $depType) ); } $dep = $this->registry->get($depType, $depName); $assets[$depType][$depName] = $dep; if (!$recursively) { continue; } $parentDeps = $this->getDependenciesForAsset($depType, $dep, true, $recursionType, $recursionRoot); $assets = array_replace_recursive($assets, $parentDeps); } return $assets; } }