Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.0] ESM importmap support #40714

Merged
merged 13 commits into from
Jun 24, 2023
21 changes: 20 additions & 1 deletion build/build-modules-js/init/localise-packages.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ const { tinyMCE } = require('./exemptions/tinymce.es6.js');

const RootPath = process.cwd();

/**
* Find full path for package file.
* Replacement for require.resolve(), as it is broken for packages with "exports" property.
*
* @param {string} relativePath Relative path to the file to resolve, in format packageName/file-name.js
* @returns {string|boolean}
*/
const resolvePackageFile = (relativePath) => {
for (let i = 0, l = module.paths.length; i < l; i += 1) {
const path = module.paths[i];
const fullPath = `${path}/${relativePath}`;
if (existsSync(fullPath)) {
return fullPath;
}
}

return false;
};

/**
*
* @param {object} files the object of files map, eg {"src.js": "js/src.js"}
Expand Down Expand Up @@ -39,7 +58,7 @@ const copyFilesTo = async (files, srcDir, destDir) => {
*/
const resolvePackage = async (vendor, packageName, mediaVendorPath, options, registry) => {
const vendorName = vendor.name || packageName;
const modulePathJson = require.resolve(`${packageName}/package.json`);
const modulePathJson = resolvePackageFile(`${packageName}/package.json`);
const modulePathRoot = dirname(modulePathJson);
// eslint-disable-next-line global-require, import/no-dynamic-require
const moduleOptions = require(modulePathJson);
Expand Down
1 change: 1 addition & 0 deletions build/build-modules-js/init/minify-vendor.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const folders = [
'media/vendor/codemirror',
'media/vendor/debugbar',
'media/vendor/diff/js',
'media/vendor/es-module-shims/js',
'media/vendor/qrcode/js',
'media/vendor/short-and-sweet/js',
'media/vendor/webcomponentsjs/js',
Expand Down
18 changes: 18 additions & 0 deletions build/build-modules-js/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,24 @@
"dependencies": [],
"licenseFilename": "license"
},
"es-module-shims": {
"name": "es-module-shims",
"js": {
"dist/es-module-shims.js": "js/es-module-shims.js"
},
"provideAssets": [
{
"name": "es-module-shims",
"type": "script",
"uri": "es-module-shims.min.js",
"attributes": {
"async": true
}
}
],
"dependencies": [],
"licenseFilename": "LICENSE"
},
"focus-visible": {
"name": "focus-visible",
"js": {
Expand Down
78 changes: 77 additions & 1 deletion libraries/src/Document/Renderer/Html/ScriptsRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public function render($head, $params = [], $content = null)
$inlineAssets = $wam->filterOutInlineAssets($assets);
$inlineRelation = $wam->getInlineRelation($inlineAssets);

// Generate importmap first
$buffer .= $this->renderImportmap($assets);

// Merge with existing scripts, for rendering
$assets = array_merge(array_values($assets), $this->_doc->_scripts);

Expand Down Expand Up @@ -138,7 +141,7 @@ private function renderElement($item): string
$src = $asset ? $asset->getUri() : ($item['src'] ?? '');

// Make sure we have a src, and it not already rendered
if (!$src || !empty($this->renderedSrc[$src]) || ($asset && $asset->getOption('webcomponent'))) {
if (!$src || !empty($this->renderedSrc[$src])) {
return '';
}

Expand Down Expand Up @@ -320,4 +323,77 @@ private function renderAttributes(array $attributes): string

return $buffer;
}

/**
* Renders ESM importmap element
*
* @param WebAssetItemInterface[] $assets The assets list
*
* @return string The attributes string
*
* @since __DEPLOY_VERSION__
*/
private function renderImportmap(array &$assets)
{
$buffer = '';
$importmap = ['imports' => []];
$tab = $this->_doc->_getTab();
$mediaVersion = $this->_doc->getMediaVersion();

// Collect a modules for the map
foreach ($assets as $k => $item) {
// Only importmap:true can be mapped
if (!$item->getOption('importmap')) {
continue;
}

$esmName = $item->getOption('importmapName') ?: $item->getName();
$esmScope = $item->getOption('importmapScope');
$version = $item->getVersion();
$src = $item->getUri();

if (!$src) {
continue;
}

// Check if script uses media version.
if ($version && !str_contains($src, '?') && !str_ends_with($src, '/') && ($mediaVersion || $version !== 'auto')) {
$src .= '?' . ($version === 'auto' ? $mediaVersion : $version);
}

if (!$esmScope) {
$importmap['imports'][$esmName] = $src;
} else {
$importmap['scopes'][$esmScope][$esmName] = $src;
}

// Remove the item from list of assets after it were added to the map.
unset($assets[$k]);
}

if (!empty($importmap['imports'])) {
// Add polyfill when exists
if (!empty($assets['es-module-shims'])) {
$buffer .= $this->renderElement($assets['es-module-shims']);
}

// Render importmap
$jsonImports = json_encode($importmap, JDEBUG ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES);
$attribs = ['type' => 'importmap'];

// Add "nonce" attribute if exist
if ($this->_doc->cspNonce && !is_null($this->_doc->cspNonce)) {
$attribs['nonce'] = $this->_doc->cspNonce;
}

$buffer .= $tab . '<script';
$buffer .= $this->renderAttributes($attribs);
$buffer .= '>' . $jsonImports . '</script>';
}

// Remove polyfill for "importmap" from assets list
unset($assets['es-module-shims']);

return $buffer;
}
}
5 changes: 5 additions & 0 deletions libraries/src/WebAsset/WebAssetItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace Joomla\CMS\WebAsset;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Uri\Uri;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
Expand Down Expand Up @@ -175,6 +176,10 @@ public function getUri($resolvePath = true): string
$path = $this->resolvePath($path, 'stylesheet');
break;
default:
// Asset for the ES modules may give us a folder for ESM import map
if (str_ends_with($path, '/') && !str_starts_with($path, '.')) {
$path = Uri::root(true) . '/' . $path;
}
break;
}
}
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"diff": "^5.1.0",
"dotenv": "^16.0.3",
"dragula": "^3.7.3",
"es-module-shims": "^1.7.3",
"focus-visible": "^5.2.0",
"hotkeys-js": "^3.10.2",
"joomla-ui-custom-elements": "^0.2.0",
Expand Down