diff --git a/administrator/templates/atum/index.php b/administrator/templates/atum/index.php index 2000a7ff24e8a..4e79762a42e02 100644 --- a/administrator/templates/atum/index.php +++ b/administrator/templates/atum/index.php @@ -19,6 +19,7 @@ $app = Factory::getApplication(); $lang = Factory::getLanguage(); $input = $app->input; +$wa = $this->getWebAssetManager(); // Detecting Active Variables $option = $input->get('option', ''); @@ -31,20 +32,8 @@ $logo = $this->baseurl . '/templates/' . $this->template . '/images/logo.svg'; $logoBlue = $this->baseurl . '/templates/' . $this->template . '/images/logo-blue.svg'; -// Add JavaScript -HTMLHelper::_('bootstrap.framework'); -HTMLHelper::_('script', 'vendor/focus-visible/focus-visible.min.js', ['version' => 'auto', 'relative' => true]); -HTMLHelper::_('script', 'vendor/css-vars-ponyfill/css-vars-ponyfill.min.js', ['version' => 'auto', 'relative' => true]); - -// Load the dependencies CSS files -HTMLHelper::_('stylesheet', 'bootstrap.css', ['version' => 'auto', 'relative' => true]); -HTMLHelper::_('stylesheet', 'font-awesome.css', ['version' => 'auto', 'relative' => true]); - -// Load the template CSS file -HTMLHelper::_('stylesheet', 'template' . ($this->direction === 'rtl' ? '-rtl' : '') . '.css', ['version' => 'auto', 'relative' => true]); - -// Load custom CSS file -HTMLHelper::_('stylesheet', 'user.css', array('version' => 'auto', 'relative' => true)); +// Enable assets +$wa->enableAsset('template.atum.' . ($this->direction === 'rtl' ? 'rtl' : 'ltr')); // Load specific language related CSS HTMLHelper::_('stylesheet', 'administrator/language/' . $lang->getTag() . '/' . $lang->getTag() . '.css', array('version' => 'auto')); @@ -54,7 +43,7 @@ // @TODO sync with _variables.scss $this->setMetaData('theme-color', '#1c3d5c'); -$this->addScriptDeclaration('cssVars();') +$this->addScriptDeclaration('cssVars();'); ?> diff --git a/administrator/templates/atum/joomla.asset.json b/administrator/templates/atum/joomla.asset.json new file mode 100644 index 0000000000000..9c17c08429ad0 --- /dev/null +++ b/administrator/templates/atum/joomla.asset.json @@ -0,0 +1,48 @@ +{ + "name": "atum", + "version": "4.0.0", + "description": "Atum is the Joomla 4 administrator template", + "license": "GPL-2.0+", + "assets": { + "template.atum.base": { + "name": "template.atum.base", + "dependencies": [ + "core", + "jquery-noconflict", + "font-awesome", + "bootstrap.css", + "bootstrap.js.bundle", + "css-vars-ponyfill" + ], + "js": [] + }, + "template.atum.ltr": { + "name": "template.atum.ltr", + "dependencies": ["template.atum.base"], + "css": [ + "template.min.css", + "user.css" + ] + }, + "template.atum.rtl": { + "name": "template.atum.rtl", + "dependencies": ["template.atum.base"], + "css": [ + "template-rtl.min.css", + "user.css" + ] + }, + "bootstrap.css": { + "name": "bootstrap.css", + "css": [ + "bootstrap.min.css" + ] + }, + "font-awesome": { + "name": "font-awesome", + "css": [ + "font-awesome.min.css" + ] + } + } +} diff --git a/build/build-modules-js/settings.json b/build/build-modules-js/settings.json index aa06ec7a8e2ae..ecf303236c501 100644 --- a/build/build-modules-js/settings.json +++ b/build/build-modules-js/settings.json @@ -71,6 +71,13 @@ "awesomplete.min.js.map": "js/awesomplete.min.js.map", "awesomplete.css.map": "css/awesomplete.css.map" }, + "provideAssets": [ + { + "name": null, + "js": ["awesomplete.min.js"], + "css": ["awesomplete.css"] + } + ], "dependencies": [], "licenseFilename": "LICENSE" }, @@ -100,6 +107,27 @@ "dist/css/bootstrap-reboot.min.css.map": "css/bootstrap-reboot.min.css.map", "scss": "scss" }, + "provideAssets": [ + { + "name": "bootstrap.js", + "js": ["bootstrap.min.js"], + "dependencies": ["jquery"] + }, + { + "name": "bootstrap.js.bundle", + "js": ["bootstrap.bundle.min.js"], + "dependencies": ["jquery"] + }, + { + "name": "bootstrap.css", + "css": ["bootstrap.min.css"] + }, + { + "name": "bootstrap.css.grid", + "css": ["bootstrap-grid.min.css"], + "dependencies": ["bootstrap.css"] + } + ], "dependencies": [], "licenseFilename": "LICENSE" }, @@ -113,6 +141,13 @@ "dist/cropper.css": "css/cropper.css", "dist/cropper.min.css": "css/cropper.min.css" }, + "provideAssets": [ + { + "name": null, + "js": ["cropper.min.js"], + "css": ["cropper.min.css"] + } + ], "dependencies": [], "licenseFilename": "LICENSE" }, @@ -122,6 +157,12 @@ "dist/diff.js": "js/diff.js", "dist/diff.min.js": "js/diff.min.js" }, + "provideAssets": [ + { + "name": null, + "js": ["diff.js"] + } + ], "dependencies": [], "licenseFilename": "LICENSE" }, @@ -135,6 +176,13 @@ "dist/dragula.css": "css/dragula.css", "dist/dragula.min.css": "css/dragula.min.css" }, + "provideAssets": [ + { + "name": null, + "js": ["dragula.min.js"], + "css": ["dragula.min.css"] + } + ], "dependencies": [], "licenseFilename": "license" }, @@ -145,6 +193,12 @@ "dist/focus-visible.min.js": "js/focus-visible.min.js", "dist/focus-visible.min.js.map": "js/focus-visible.min.js.map" }, + "provideAssets": [ + { + "name": null, + "js": ["focus-visible.min.js"] + } + ], "dependencies": [], "licenseFilename": "LICENSE.md" }, @@ -159,6 +213,12 @@ "scss": "scss", "fonts": "fonts" }, + "provideAssets": [ + { + "name": null, + "css": ["font-awesome.min.css"] + } + ], "dependencies": [], "licenseFilename": "" }, @@ -171,8 +231,11 @@ "filesExtra": { "dist/jquery.min.map": "js/jquery.min.map" }, - "include": [ - "jquery-migrate" + "provideAssets": [ + { + "name": null, + "js": ["jquery.min.js"] + } ], "dependencies": [], "licenseFilename": "LICENSE.txt" @@ -183,6 +246,13 @@ "dist/jquery-migrate.js": "js/jquery-migrate.js", "dist/jquery-migrate.min.js": "js/jquery-migrate.min.js" }, + "provideAssets": [ + { + "name": null, + "js": ["jquery-migrate.min.js"], + "dependencies": ["jquery"] + } + ], "dependencies": [ "jquery" ], @@ -240,6 +310,12 @@ "js": { "punycode.js": "js/punycode.js" }, + "provideAssets": [ + { + "name": null, + "js": ["punycode.js"] + } + ], "dependencies": [], "licenseFilename": "LICENSE-MIT.txt" }, @@ -255,6 +331,14 @@ "filesExtra": { "jquery.minicolors.png": "css/jquery.minicolors.png" }, + "provideAssets": [ + { + "name": null, + "js": ["jquery.minicolors.min.js"], + "css": ["jquery.minicolors.css"], + "dependencies": ["jquery"] + } + ], "dependencies": [ "jquery" ], @@ -286,7 +370,13 @@ "dist/css-vars-ponyfill.js": "js/css-vars-ponyfill.js", "dist/css-vars-ponyfill.min.js": "js/css-vars-ponyfill.min.js", "dist/css-vars-ponyfill.min.js.map": "js/css-vars-ponyfill.min.js.map" - } + }, + "provideAssets": [ + { + "name": null, + "js": ["css-vars-ponyfill.min.js"] + } + ] }, "chosen-js": { "name": "chosen", @@ -297,7 +387,15 @@ "chosen.css": "css/chosen.css", "chosen-sprite.png": "css/chosen-sprite.png", "chosen-sprite@2x.png": "css/chosen-sprite@2x.png" - } + }, + "provideAssets": [ + { + "name": null, + "js": ["chosen.jquery.js"], + "css": ["chosen.css"], + "dependencies": ["jquery"] + } + ] } }, "errorPages": { diff --git a/build/build-modules-js/update.js b/build/build-modules-js/update.js index 2d474e089bf81..f37c146c1b28a 100644 --- a/build/build-modules-js/update.js +++ b/build/build-modules-js/update.js @@ -88,7 +88,7 @@ const copyFiles = (options) => { version: options.version, description: options.description, license: options.license, - vendors: {}, + assets: {}, }; if (!fsExtra.existsSync(mediaVendorPath)) { @@ -105,13 +105,6 @@ const copyFiles = (options) => { // eslint-disable-next-line global-require, import/no-dynamic-require const moduleOptions = require(modulePathJson); - const registryItem = { - package: packageName, - name: vendorName, - version: moduleOptions.version, - dependencies: vendor.dependencies || [], - }; - if (packageName === 'codemirror') { const itemvendorPath = Path.join(rootPath, `media/vendor/${packageName}`); if (!fsExtra.existsSync(itemvendorPath)) { @@ -197,15 +190,7 @@ const copyFiles = (options) => { if (!vendor[type]) return; const dest = Path.join(mediaVendorPath, vendorName); - const files = copyFilesTo(vendor[type], modulePathRoot, dest, type); - - // Add to registry, in format suported by JHtml - if (type === 'js' || type === 'css') { - registryItem[type] = []; - files.forEach((filePath) => { - registryItem[type].push(`vendor/${vendorName}/${Path.basename(filePath)}`); - }); - } + copyFilesTo(vendor[type], modulePathRoot, dest, type); }); // Copy the license if exists @@ -229,18 +214,64 @@ const copyFiles = (options) => { fs.writeFileSync(chosenPath, ChosenJs, { encoding: 'UTF-8' }); } - registry.vendors[vendorName] = registryItem; + // Add provided Assets to a registry, if any + if (vendor.provideAssets && vendor.provideAssets.length) { + vendor.provideAssets.forEach((assetInfo) => { + + const registryItem = { + package: packageName, + name: assetInfo.name || vendorName, + version: moduleOptions.version, + dependencies: assetInfo.dependencies || [], + js: [], + css: [], + attribute: {} + }; + + // Update path for JS and CSS files + assetInfo.js && assetInfo.js.length && assetInfo.js.forEach((assetJS) => { + let itemPath = assetJS; + + // Check for external path + if (itemPath.indexOf('http://') !== 0 && itemPath.indexOf('https://') !== 0 && itemPath.indexOf('//') !== 0) { + itemPath = `media/vendor/${vendorName}/js/${itemPath}`; + } + registryItem.js.push(itemPath); + + // Check if there are any attribute to this file, then update the path + if (assetInfo.attribute && assetInfo.attribute[assetJS]) { + registryItem.attribute[itemPath] = assetInfo.attribute[assetJS] + } + }); + assetInfo.css && assetInfo.css.length && assetInfo.css.forEach((assetCSS) => { + let itemPath = assetCSS; + + // Check for external path + if (itemPath.indexOf('http://') !== 0 && itemPath.indexOf('https://') !== 0 && itemPath.indexOf('//') !== 0) { + itemPath = `media/vendor/${vendorName}/css/${itemPath}`; + } + registryItem.css.push(itemPath); + + // Check if there are any attribute to this file, then update the path + if (assetInfo.attribute && assetInfo.attribute[assetCSS]) { + registryItem.attribute[itemPath] = assetInfo.attribute[assetCSS] + } + }); + + registry.assets[registryItem.name] = registryItem; + }); + } // eslint-disable-next-line no-console console.log(`${packageName} was updated.`); } // Write assets registry - // fs.writeFileSync( - // Path.join(mediaVendorPath, 'joomla.asset.json'), - // JSON.stringify(registry, null, 2), - // {encoding: 'UTF-8'} - // ); + fs.writeFileSync( + Path.join(mediaVendorPath, 'joomla.asset.json'), + JSON.stringify(registry, null, 2), + {encoding: 'UTF-8'} + ); }; const recreateMediaFolder = () => { diff --git a/build/media/legacy/joomla.asset.json b/build/media/legacy/joomla.asset.json new file mode 100644 index 0000000000000..3a8ea5875373f --- /dev/null +++ b/build/media/legacy/joomla.asset.json @@ -0,0 +1,17 @@ +{ + "name": "joomla", + "version": "4.0.0", + "description": "Joomla CMS", + "license": "GPL-2.0+", + "assets": { + "jquery-noconflict": { + "name": "jquery-noconflict", + "dependencies": [ + "jquery" + ], + "js": [ + "media/legacy/js/jquery-noconflict.min.js" + ] + } + } +} diff --git a/build/media_src/system/joomla.asset.json b/build/media_src/system/joomla.asset.json new file mode 100644 index 0000000000000..017321869e31d --- /dev/null +++ b/build/media_src/system/joomla.asset.json @@ -0,0 +1,60 @@ +{ + "name": "joomla", + "version": "4.0.0", + "description": "Joomla CMS", + "license": "GPL-2.0+", + "assets": { + "core": { + "name": "core", + "js": [ + "media/system/js/core.min.js" + ] + }, + "keepalive": { + "name": "keepalive", + "dependencies": [ + "core" + ], + "js": [ + "media/system/js/keepalive.min.js" + ] + }, + "multiselect": { + "name": "multiselect", + "dependencies": [ + "core" + ], + "js": [ + "media/system/js/multiselect.min.js" + ] + }, + "searchtools": { + "name": "searchtools", + "dependencies": [ + "core" + ], + "js": [ + "media/system/js/searchtools.min.js" + ] + }, + "showon": { + "name": "showon", + "dependencies": [ + "core" + ], + "js": [ + "media/system/js/showon.min.js" + ] + }, + "fields.validate": { + "name": "fields.validate", + "dependencies": [ + "core", + "punycode" + ], + "js": [ + "media/system/js/fields/validate.min.js" + ] + } + } +} diff --git a/libraries/cms/html/behavior.php b/libraries/cms/html/behavior.php index daf3815f568c5..7b57fbbfe90db 100644 --- a/libraries/cms/html/behavior.php +++ b/libraries/cms/html/behavior.php @@ -68,7 +68,7 @@ public static function core() } HTMLHelper::_('form.csrf'); - HTMLHelper::_('script', 'system/core.min.js', array('version' => 'auto', 'relative' => true)); + Factory::getContainer()->get('webasset')->enableAsset('core'); // Add core and base uri paths so javascript scripts can use them. Factory::getDocument()->addScriptOptions( @@ -140,8 +140,7 @@ public static function formvalidator() Text::script('JLIB_FORM_FIELD_REQUIRED_CHECK'); Text::script('JLIB_FORM_FIELD_INVALID_VALUE'); - HTMLHelper::_('script', 'vendor/punycode/punycode.js', array('version' => 'auto', 'relative' => true)); - HTMLHelper::_('script', 'system/fields/validate.min.js', array('version' => 'auto', 'relative' => true)); + Factory::getContainer()->get('webasset')->enableAsset('fields.validate'); static::$loaded[__METHOD__] = true; } @@ -170,18 +169,7 @@ public static function switcher() */ public static function combobox() { - if (isset(static::$loaded[__METHOD__])) - { - return; - } - - // Include core - static::core(); - - HTMLHelper::_('stylesheet', 'vendor/awesomplete/awesomplete.css', array('version' => 'auto', 'relative' => true)); - HTMLHelper::_('script', 'vendor/awesomplete/awesomplete.js', array('version' => 'auto', 'relative' => true)); - - static::$loaded[__METHOD__] = true; + Factory::getContainer()->get('webasset')->enableAsset('awesomplete'); } /** @@ -259,10 +247,7 @@ public static function multiselect($id = 'adminForm') return; } - // Include core - static::core(); - - HTMLHelper::_('script', 'system/multiselect.min.js', array('version' => 'auto', 'relative' => true)); + Factory::getContainer()->get('webasset')->enableAsset('multiselect'); // Pass the required options to the javascript Factory::getDocument()->addScriptOptions('js-multiselect', ['formName' => $id]); @@ -438,14 +423,10 @@ public static function keepalive() // If we are in the frontend or logged in as a user, we can use the ajax component to reduce the load $uri = 'index.php' . ($app->isClient('site') || !Factory::getUser()->guest ? '?option=com_ajax&format=json' : ''); - // Include core - static::core(); - // Add keepalive script options. Factory::getDocument()->addScriptOptions('system.keepalive', array('interval' => $refreshTime * 1000, 'uri' => Route::_($uri))); - // Add script. - HTMLHelper::_('script', 'system/keepalive.js', array('version' => 'auto', 'relative' => true)); + Factory::getContainer()->get('webasset')->enableAsset('keepalive'); static::$loaded[__METHOD__] = true; diff --git a/libraries/cms/html/bootstrap.php b/libraries/cms/html/bootstrap.php index 4d8213bcafc65..2984164a705a5 100644 --- a/libraries/cms/html/bootstrap.php +++ b/libraries/cms/html/bootstrap.php @@ -159,9 +159,9 @@ public static function framework($debug = null) $debug = (isset($debug) && $debug != JDEBUG) ? $debug : JDEBUG; // Load the needed scripts - HTMLHelper::_('behavior.core'); - HTMLHelper::_('jquery.framework'); - HTMLHelper::_('script', 'vendor/bootstrap/bootstrap.bundle.min.js', array('version' => 'auto', 'relative' => true, 'detectDebug' => $debug)); + Factory::getContainer()->get('webasset') + ->enableAsset('core') + ->enableAsset('bootstrap.js.bundle'); HTMLHelper::_('script', 'legacy/bootstrap-init.min.js', array('version' => 'auto', 'relative' => true, 'detectDebug' => $debug)); static::$loaded[__METHOD__] = true; diff --git a/libraries/src/Application/AdministratorApplication.php b/libraries/src/Application/AdministratorApplication.php index b6ff3ed1480bc..e445845a3b7fb 100644 --- a/libraries/src/Application/AdministratorApplication.php +++ b/libraries/src/Application/AdministratorApplication.php @@ -98,6 +98,11 @@ public function dispatch($component = null) $this->set('theme', $template->template); $this->set('themeParams', $template->params); + // Add Asset registry files + $document->getWebAssetManager() + ->addRegistryFile('media/' . $component . '/joomla.asset.json') + ->addRegistryFile('administrator/templates/' . $template->template . '/joomla.asset.json'); + break; default: diff --git a/libraries/src/Application/SiteApplication.php b/libraries/src/Application/SiteApplication.php index 6cdaf3f447b05..0de7bf08e38c4 100644 --- a/libraries/src/Application/SiteApplication.php +++ b/libraries/src/Application/SiteApplication.php @@ -181,6 +181,11 @@ public function dispatch($component = null) $this->set('theme', $template->template); $this->set('themeParams', $template->params); + // Add Asset registry files + $document->getWebAssetManager() + ->addRegistryFile('media/' . $component . '/joomla.asset.json') + ->addRegistryFile('templates/' . $template->template . '/joomla.asset.json'); + break; case 'feed': diff --git a/libraries/src/Document/Document.php b/libraries/src/Document/Document.php index f5dda7fc6f163..7a11794debf5c 100644 --- a/libraries/src/Document/Document.php +++ b/libraries/src/Document/Document.php @@ -14,6 +14,7 @@ use Joomla\CMS\Date\Date; use Joomla\CMS\Factory as CmsFactory; use Joomla\CMS\Log\Log; +use Joomla\CMS\WebAsset\WebAssetRegistry; use Symfony\Component\WebLink\HttpHeaderSerializer; /** @@ -246,6 +247,14 @@ class Document */ protected $preloadTypes = ['preload', 'dns-prefetch', 'preconnect', 'prefetch', 'prerender']; + /** + * Web Asset instance + * + * @var WebAssetRegistry + * @since __DEPLOY_VERSION__ + */ + protected $webAssetManager = null; + /** * Class constructor. * @@ -312,6 +321,15 @@ public function __construct($options = array()) { $this->setPreloadManager(new PreloadManager); } + + if (array_key_exists('webAsset', $options)) + { + $this->setWebAssetManager($options['webAsset']); + } + else + { + $this->setWebAssetManager(\Joomla\CMS\Factory::getContainer()->get('webasset')); + } } /** @@ -977,6 +995,34 @@ public function getPreloadManager(): PreloadManagerInterface return $this->preloadManager; } + /** + * Set WebAsset manager + * + * @param WebAssetRegistry $webAsset The WebAsset instance + * + * @return Document + * + * @since __DEPLOY_VERSION__ + */ + public function setWebAssetManager(WebAssetRegistry $webAsset): self + { + $this->webAssetManager = $webAsset; + + return $this; + } + + /** + * Return WebAsset manager + * + * @return WebAssetRegistry + * + * @since __DEPLOY_VERSION__ + */ + public function getWebAssetManager(): WebAssetRegistry + { + return $this->webAssetManager; + } + /** * Sets the base URI of the document * diff --git a/libraries/src/Document/Renderer/Html/MetasRenderer.php b/libraries/src/Document/Renderer/Html/MetasRenderer.php index 3b8cdcde74fdb..e6aa0b7d4c350 100644 --- a/libraries/src/Document/Renderer/Html/MetasRenderer.php +++ b/libraries/src/Document/Renderer/Html/MetasRenderer.php @@ -49,6 +49,10 @@ public function render($head, $params = array(), $content = null) HTMLHelper::_('behavior.core'); } + // Attach Assets + $wa = $this->_doc->getWebAssetManager(); + $wa->attachActiveAssetsToDocument($this->_doc); + // Trigger the onBeforeCompileHead event $app = Factory::getApplication(); $app->triggerEvent('onBeforeCompileHead'); diff --git a/libraries/src/Event/WebAsset/AbstractEvent.php b/libraries/src/Event/WebAsset/AbstractEvent.php new file mode 100644 index 0000000000000..a24a996055a50 --- /dev/null +++ b/libraries/src/Event/WebAsset/AbstractEvent.php @@ -0,0 +1,64 @@ +name} is required but has not been provided"); + } + + parent::__construct($name, $arguments); + } + + /** + * Setter for the subject argument + * + * @param WebAssetRegistry $value The value to set + * + * @return WebAssetRegistry + * + * @throws BadMethodCallException if the argument is not of the expected type + * + * @since __DEPLOY_VERSION__ + */ + protected function setSubject($value) + { + if (!$value || !($value instanceof WebAssetRegistry)) + { + throw new BadMethodCallException("Argument 'subject' of event {$this->name} is not of the expected type"); + } + + return $value; + } +} diff --git a/libraries/src/Event/WebAsset/WebAssetBeforeAttachEvent.php b/libraries/src/Event/WebAsset/WebAssetBeforeAttachEvent.php new file mode 100644 index 0000000000000..d7d91809f359b --- /dev/null +++ b/libraries/src/Event/WebAsset/WebAssetBeforeAttachEvent.php @@ -0,0 +1,55 @@ +arguments['document']; + } +} diff --git a/libraries/src/Event/WebAsset/WebAssetStateChangedEvent.php b/libraries/src/Event/WebAsset/WebAssetStateChangedEvent.php new file mode 100644 index 0000000000000..0612505ff8e20 --- /dev/null +++ b/libraries/src/Event/WebAsset/WebAssetStateChangedEvent.php @@ -0,0 +1,87 @@ +arguments['asset']; + } + + /** + * Get previous state of the asset + * + * @return int + * + * @since __DEPLOY_VERSION__ + */ + public function getOldState(): int + { + return (int) $this->arguments['oldState']; + } + + /** + * Get new state of the asset + * + * @return int + * + * @since __DEPLOY_VERSION__ + */ + public function getNewState(): int + { + return (int) $this->arguments['newState']; + } +} diff --git a/libraries/src/Factory.php b/libraries/src/Factory.php index 33cccbae21993..dbb190dd45186 100644 --- a/libraries/src/Factory.php +++ b/libraries/src/Factory.php @@ -514,7 +514,8 @@ protected static function createContainer(): Container ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Pathway) ->registerServiceProvider(new \Joomla\CMS\Service\Provider\HTMLRegistry) ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Session) - ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Toolbar); + ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Toolbar) + ->registerServiceProvider(new \Joomla\CMS\Service\Provider\WebAsset); return $container; } diff --git a/libraries/src/Service/Provider/WebAsset.php b/libraries/src/Service/Provider/WebAsset.php new file mode 100644 index 0000000000000..e465fcf445eeb --- /dev/null +++ b/libraries/src/Service/Provider/WebAsset.php @@ -0,0 +1,55 @@ +alias('webasset', WebAssetRegistry::class) + ->share( + WebAssetRegistry::class, + function (Container $container) + { + $registry = new WebAssetRegistry; + + // Set up Dispatcher + $registry->setDispatcher($container->get('Joomla\Event\DispatcherInterface')); + + // Add Core registry files + $registry->addRegistryFile('media/system/joomla.asset.json') + ->addRegistryFile('media/vendor/joomla.asset.json') + ->addRegistryFile('media/legacy/joomla.asset.json'); + + return $registry; + }, + true + ); + } +} diff --git a/libraries/src/WebAsset/WebAssetItem.php b/libraries/src/WebAsset/WebAssetItem.php new file mode 100644 index 0000000000000..6f64de84a90d4 --- /dev/null +++ b/libraries/src/WebAsset/WebAssetItem.php @@ -0,0 +1,441 @@ +name = $name; + $this->version = !empty($data['version']) ? $data['version'] : null; + $this->assetSource = !empty($data['assetSource']) ? $data['assetSource'] : null; + + $attributes = empty($data['attribute']) ? [] : $data['attribute']; + + // Check for Scripts and StyleSheets, and their attributes + if (!empty($data['js'])) + { + foreach ($data['js'] as $js) + { + $this->js[$js] = empty($attributes[$js]) ? [] : $attributes[$js]; + } + } + + if (!empty($data['css'])) + { + foreach ($data['css'] as $css) + { + $this->css[$css] = empty($attributes[$css]) ? [] : $attributes[$css]; + } + } + + if (!empty($data['dependencies'])) + { + $this->dependencies = (array) $data['dependencies']; + } + } + + /** + * Return Asset name + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getName(): string + { + return $this->name; + } + + /** + * Return Asset version + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getVersion(): ?string + { + return $this->version; + } + + /** + * Return dependency + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + /** + * Set asset State + * + * @param int $state The asset state + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function setState(int $state): self + { + $this->state = $state; + + return $this; + } + + /** + * Get asset State + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + public function getState(): int + { + return $this->state; + } + + /** + * Check asset state + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function isActive(): bool + { + return $this->state !== self::ASSET_STATE_INACTIVE; + } + + /** + * Set the Asset weight. Final weight recalculated by AssetFactory. + * + * @param float $weight The asset weight + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function setWeight(float $weight): self + { + $this->weight = $weight; + + return $this; + } + + /** + * Return current weight of the Asset. Final weight recalculated by AssetFactory. + * + * @return float + * + * @since __DEPLOY_VERSION__ + */ + public function getWeight(): float + { + return $this->weight; + } + + /** + * Get CSS files + * + * @param boolean $resolvePath Whether need to search for real path + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getStylesheetFiles($resolvePath = true): array + { + if ($resolvePath) + { + $files = []; + + foreach ($this->css as $path => $attr) + { + $resolved = $this->resolvePath($path, 'stylesheet'); + $fullPath = $resolved['fullPath']; + + if (!$fullPath) + { + // File not found, But we keep going ??? + continue; + } + + $files[$fullPath] = $attr; + $files[$fullPath]['__isExternal'] = $resolved['external']; + $files[$fullPath]['__pathOrigin'] = $path; + } + + return $files; + } + + return $this->css; + } + + /** + * Get JS files + * + * @param boolean $resolvePath Whether we need to search for real path + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getScriptFiles($resolvePath = true): array + { + if ($resolvePath) + { + $files = []; + + foreach ($this->js as $path => $attr) + { + $resolved = $this->resolvePath($path, 'script'); + $fullPath = $resolved['fullPath']; + + if (!$fullPath) + { + // File not found, But we keep going ??? + continue; + } + + $files[$fullPath] = $attr; + $files[$fullPath]['__isExternal'] = $resolved['external']; + $files[$fullPath]['__pathOrigin'] = $path; + } + + return $files; + } + + return $this->js; + } + + /** + * Return list of the asset files, and it's attributes + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getAssetFiles(): array + { + return [ + 'script' => $this->getScriptFiles(true), + 'stylesheet' => $this->getStylesheetFiles(true), + ]; + } + + /** + * Resolve path + * + * @param string $path The path to resolve + * @param string $type The resolver method + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + protected function resolvePath(string $path, string $type): array + { + if (!empty($this->resolvedPaths[$path])) + { + return $this->resolvedPaths[$path]; + } + + if ($type !== 'script' && $type !== 'stylesheet') + { + throw new \UnexpectedValueException('Unexpected [type], expected "script" or "stylesheet"'); + } + + $file = $path; + $external = $this->isPathExternal($path); + + if (!$external) + { + // Get the file path + $file = HTMLHelper::_( + $type, + $path, + [ + 'pathOnly' => true, + 'relative' => !$this->isPathAbsolute($path) + ] + ); + } + + $this->resolvedPaths[$path] = [ + 'external' => $external, + 'fullPath' => $file ? $file : false, + ]; + + return $this->resolvedPaths[$path]; + } + + /** + * Check if the Path is External + * + * @param string $path Path to test + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + protected function isPathExternal(string $path): bool + { + return strpos($path, 'http://') === 0 || strpos($path, 'https://') === 0 || strpos($path, '//') === 0; + } + + /** + * Check if the Path is relative to /media folder or absolute + * + * @param string $path Path to test + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + protected function isPathAbsolute(string $path): bool + { + // We have a full path or not + return is_file(JPATH_ROOT . '/' . $path); + } +} diff --git a/libraries/src/WebAsset/WebAssetRegistry.php b/libraries/src/WebAsset/WebAssetRegistry.php new file mode 100644 index 0000000000000..2bebbdd58d305 --- /dev/null +++ b/libraries/src/WebAsset/WebAssetRegistry.php @@ -0,0 +1,653 @@ +parseRegistryFiles(); + + if (!empty($this->assets[$name])) + { + return $this->assets[$name]; + } + + return null; + } + + /** + * Search for all active assets. + * + * @return WebAssetItem[] Array with active assets + * + * @since __DEPLOY_VERSION__ + */ + public function getActiveAssets(): array + { + $assets = array_filter( + $this->assets, + function($asset) + { + return $asset->isActive(); + } + ); + + // Order them by weight and return + return $assets ? $this->sortByWeight($assets) : []; + } + + /** + * Search for assets with specific state. + * + * @param int $state Asset state + * + * @return WebAssetItem[] Array with active assets + * + * @since __DEPLOY_VERSION__ + */ + public function getAssetsByState(int $state = WebAssetItem::ASSET_STATE_ACTIVE): array + { + $assets = array_filter( + $this->assets, + function($asset) use ($state) + { + return $asset->getState() === $state; + } + ); + + // Order them by weight and return + return $assets ? $this->sortByWeight($assets) : []; + } + + /** + * Add Asset to registry of known assets + * + * @param WebAssetItem $asset Asset instance + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function addAsset(WebAssetItem $asset): self + { + // Check whether the asset already exists, so we must copy its state before override + if (!empty($this->assets[$asset->getName()])) + { + $existing = $this->assets[$asset->getName()]; + $asset->setState($existing->getState()); + } + + $this->assets[$asset->getName()] = $asset; + + return $this; + } + + /** + * Remove Asset from registry. + * + * @param string $name Asset name + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function removeAsset(string $name): self + { + if (!empty($this->assets[$name])) + { + unset($this->assets[$name]); + } + + return $this; + } + + /** + * Change the asset State + * + * @param string $name Asset name + * @param integer $state New state + * + * @return self + * + * @throws \RuntimeException if asset with given name does not exists + * + * @since __DEPLOY_VERSION__ + */ + public function setAssetState(string $name, int $state = WebAssetItem::ASSET_STATE_ACTIVE): self + { + $asset = $this->getAsset($name); + + if (!$asset) + { + throw new \RuntimeException('Asset "' . $name . '" do not exists'); + } + + $oldState = $asset->getState(); + + // Asset already has the requested state + if ($oldState === $state) + { + return $this; + } + + // Change state + $asset->setState($state); + + // Update last weight, to keep an order of enabled items + if ($asset->isActive()) + { + $this->lastItemWeight = $this->lastItemWeight + 1; + $asset->setWeight($this->lastItemWeight); + } + + // Trigger the event + $event = AbstractEvent::create( + 'onWebAssetStateChanged', + [ + 'eventClass' => 'Joomla\\CMS\\Event\\WebAsset\\WebAssetStateChangedEvent', + 'subject' => $this, + 'asset' => $asset, + 'oldState' => $oldState, + 'newState' => $state, + ] + ); + $this->getDispatcher()->dispatch($event->getName(), $event); + + return $this; + } + + /** + * Activate the Asset item + * + * @param string $name The asset name + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function enableAsset(string $name): self + { + return $this->setAssetState($name, WebAssetItem::ASSET_STATE_ACTIVE); + } + + /** + * Deactivate the Asset item + * + * @param string $name The asset name + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function disableAsset(string $name): self + { + return $this->setAssetState($name, WebAssetItem::ASSET_STATE_INACTIVE); + } + + /** + * Attach active assets to the document + * + * @param Document $doc Document for attach StyleSheet/JavaScript + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function attachActiveAssetsToDocument(Document $doc): self + { + // Resolve Dependency + $this->resolveDependency(); + + // Trigger the event + $event = AbstractEvent::create( + 'onWebAssetBeforeAttach', + [ + 'eventClass' => 'Joomla\\CMS\\Event\\WebAsset\\WebAssetBeforeAttachEvent', + 'subject' => $this, + 'document' => $doc, + ] + ); + $this->getDispatcher()->dispatch($event->getName(), $event); + + $assets = $this->getActiveAssets(); + + // Pre-save existing Scripts, and attach them after requested assets. + $jsBackup = $doc->_scripts; + $doc->_scripts = []; + + // Attach active assets to the document + foreach ($assets as $asset) + { + $paths = $asset->getAssetFiles(); + + // Add StyleSheets of the asset + foreach ($paths['stylesheet'] as $path => $attr) + { + unset($attr['__isExternal'], $attr['__pathOrigin']); + $version = $this->useVersioning ? ($asset->getVersion() ?: 'auto') : false; + $doc->addStyleSheet($path, ['version' => $version], $attr); + } + + // Add Scripts of the asset + foreach ($paths['script'] as $path => $attr) + { + unset($attr['__isExternal'], $attr['__pathOrigin']); + $version = $this->useVersioning ? ($asset->getVersion() ?: 'auto') : false; + $doc->addScript($path, ['version' => $version], $attr); + } + } + + // Merge with previously added scripts + $doc->_scripts = array_replace($doc->_scripts, $jsBackup); + + return $this; + } + + /** + * Resolve Dependency for just added assets + * + * @return self + * + * @throws \RuntimeException When Dependency cannot be resolved + * + * @since __DEPLOY_VERSION__ + */ + protected function resolveDependency(): self + { + $assets = $this->getAssetsByState(WebAssetItem::ASSET_STATE_ACTIVE); + + foreach ($assets as $asset) + { + $this->resolveItemDependency($asset); + $asset->setState(WebAssetItem::ASSET_STATE_RESOLVED); + } + + return $this; + } + + /** + * Resolve Dependency for given asset + * + * @param WebAssetItem $asset Asset instance + * + * @return self + * + * @throws \RuntimeException When Dependency cannot be resolved + * + * @since __DEPLOY_VERSION__ + */ + protected function resolveItemDependency(WebAssetItem $asset): self + { + foreach ($this->getDependenciesForAsset($asset) as $depItem) + { + $oldState = $depItem->isActive(); + + // Make active + if (!$oldState) + { + $depItem->setState(WebAssetItem::ASSET_STATE_DEPENDANCY); + } + + // Calculate weight, make it a bit lighter + $depWeight = $depItem->getWeight(); + $assetWeight = $asset->getWeight(); + + $depWeight = $depWeight === 0 ? $this->lastItemWeight : $depWeight; + $weight = $depWeight > $assetWeight ? $assetWeight : $depWeight; + $weight = $weight - 0.01; + + $depItem->setWeight($weight); + + // Prevent duplicated work if Dependency was already activated + if (!$oldState) + { + $this->resolveItemDependency($depItem); + } + } + + return $this; + } + + /** + * Return dependancy for Asset as array of AssetItem objects + * + * @param WebAssetItem $asset Asset instance + * + * @return WebAssetItem[] + * + * @throws \RuntimeException When Dependency cannot be found + * + * @since __DEPLOY_VERSION__ + */ + protected function getDependenciesForAsset(WebAssetItem $asset): array + { + $assets = []; + + foreach ($asset->getDependencies() as $depName) + { + $dep = $this->getAsset($depName); + + if (!$dep) + { + throw new \RuntimeException('Cannot find Dependency "' . $depName . '" for Asset "' . $asset->getName() . '"'); + } + + $assets[$depName] = $dep; + } + + return $assets; + } + + /** + * Sort assets by it`s weight + * + * @param WebAssetItem[] $assets Linked array of assets + * + * @return WebAssetItem[] + * + * @since __DEPLOY_VERSION__ + */ + protected function sortByWeight(array $assets): array + { + uasort( + $assets, + function($a, $b) + { + if ($a->getWeight() === $b->getWeight()) + { + return 0; + } + + return $a->getWeight() > $b->getWeight() ? 1 : -1; + } + ); + + return $assets; + } + + /** + * Prepare new Asset instance. + * + * @param string $name Asset name + * @param array $data Asset information + * + * @return WebAssetItem + * + * @since __DEPLOY_VERSION__ + */ + public function createAsset(string $name, array $data = []): WebAssetItem + { + return new WebAssetItem($name, $data); + } + + /** + * Register new file with Asset(s) info + * + * @param string $path Relative path + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function addRegistryFile(string $path): self + { + $path = Path::clean($path); + + if (isset($this->dataFiles[$path])) + { + return $this; + } + + $this->dataFiles[$path] = is_file(JPATH_ROOT . '/' . $path) ? static::REGISTRY_FILE_NEW : static::REGISTRY_FILE_INVALID; + + return $this; + } + + /** + * Parse registered files + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function parseRegistryFiles(): void + { + // Filter new asset data files and parse each + $constantIsNew = static::REGISTRY_FILE_NEW; + $files = array_filter( + $this->dataFiles, + function($state) use ($constantIsNew) + { + return $state === $constantIsNew; + } + ); + + foreach (array_keys($files) as $path) + { + $this->parseRegistryFile($path); + + // Mark as parsed (not new) + $this->dataFiles[$path] = static::REGISTRY_FILE_PARSED; + } + } + + /** + * Parse registry file + * + * @param string $path Relative path to the data file + * + * @return void + * + * @throws \RuntimeException If file is empty or invalid + * + * @since __DEPLOY_VERSION__ + */ + protected function parseRegistryFile($path): void + { + $data = file_get_contents(JPATH_ROOT . '/' . $path); + $data = $data ? json_decode($data, true) : null; + + if (!$data) + { + throw new \RuntimeException('Asset data file "' . $path . '" is broken'); + } + + // Asset exists but empty, skip it silently + if (empty($data['assets'])) + { + return; + } + + // Keep source info + $assetSource = [ + 'registryFile' => $path, + ]; + + // Prepare WebAssetItem instances + foreach ($data['assets'] as $item) + { + if (empty($item['name'])) + { + throw new \RuntimeException('Asset data file "' . $path . '" contains incorrect asset defination'); + } + + $item['assetSource'] = $assetSource; + $assetItem = $this->createAsset($item['name'], $item); + $this->addAsset($assetItem); + } + } + + /** + * Dump available assets to simple array, with some basic info + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function debugAssets(): array + { + $assets = $this->assets; + $result = []; + + foreach ($assets as $asset) + { + $result[$asset->getName()] = [ + 'deps' => implode(', ', $asset->getDependencies()), + 'state' => $asset->getState(), + ]; + } + + return $result; + } + + /** + * Whether append asset version to asset path + * + * @param bool $useVersioning Boolean flag + * + * @return self + * + * @since __DEPLOY_VERSION__ + */ + public function useVersioning(bool $useVersioning): self + { + $this->useVersioning = $useVersioning; + + return $this; + } +} diff --git a/templates/cassiopeia/index.php b/templates/cassiopeia/index.php index fbbf14a587ea7..ec7e81c3b10c1 100644 --- a/templates/cassiopeia/index.php +++ b/templates/cassiopeia/index.php @@ -18,6 +18,7 @@ $app = Factory::getApplication(); $lang = Factory::getLanguage(); +$wa = $this->getWebAssetManager(); // Detecting Active Variables $option = $app->input->getCmd('option', ''); @@ -29,20 +30,8 @@ $menu = $app->getMenu()->getActive(); $pageclass = $menu->params->get('pageclass_sfx'); -// Add JavaScript Frameworks -HTMLHelper::_('bootstrap.framework'); - -// Add template js -HTMLHelper::_('script', 'template.js', ['version' => 'auto', 'relative' => true]); - -// Load custom Javascript file -HTMLHelper::_('script', 'user.js', ['version' => 'auto', 'relative' => true]); - -// Load template CSS file -HTMLHelper::_('stylesheet', 'template' . ($this->direction === 'rtl' ? '-rtl' : '') . '.css', ['version' => 'auto', 'relative' => true]); - -// Load custom CSS file -HTMLHelper::_('stylesheet', 'user.css', array('version' => 'auto', 'relative' => true)); +// Enable assets +$wa->enableAsset('template.cassiopeia.' . ($this->direction === 'rtl' ? 'rtl' : 'ltr')); // Load specific language related CSS HTMLHelper::_('stylesheet', 'language/' . $lang->getTag() . '/' . $lang->getTag() . '.css', array('version' => 'auto')); diff --git a/templates/cassiopeia/joomla.asset.json b/templates/cassiopeia/joomla.asset.json new file mode 100644 index 0000000000000..02833c5927458 --- /dev/null +++ b/templates/cassiopeia/joomla.asset.json @@ -0,0 +1,37 @@ +{ + "name": "cassiopeia", + "version": "4.0.0", + "description": "Cassiopeia is the Joomla 4 site template", + "license": "GPL-2.0+", + "assets": { + "template.cassiopeia.base": { + "name": "template.cassiopeia.base", + "dependencies": [ + "core", + "jquery-noconflict", + "font-awesome", + "bootstrap.js.bundle" + ], + "js": [ + "template.js", + "user.js" + ] + }, + "template.cassiopeia.ltr": { + "name": "template.cassiopeia.ltr", + "dependencies": ["template.cassiopeia.base"], + "css": [ + "template.min.css", + "user.css" + ] + }, + "template.cassiopeia.rtl": { + "name": "template.cassiopeia.rtl", + "dependencies": ["template.cassiopeia.base"], + "css": [ + "template-rtl.min.css", + "user.css" + ] + } + } +}