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

(dev/core#4279) Define import-maps for ECMAScript Modules #26197

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CRM/Admin/Form/Setting/Miscellaneous.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class CRM_Admin_Form_Setting_Miscellaneous extends CRM_Admin_Form_Setting {
'dedupe_default_limit' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
'remote_profile_submissions' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
'allow_alert_autodismissal' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
'esm_loader' => CRM_Core_BAO_Setting::DEVELOPER_PREFERENCES_NAME,
'prevNextBackend' => CRM_Core_BAO_Setting::SEARCH_PREFERENCES_NAME,
'import_batch_size' => CRM_Core_BAO_Setting::SEARCH_PREFERENCES_NAME,
];
Expand All @@ -62,6 +63,7 @@ public function preProcess() {
'recentItemsMaxCount',
'recentItemsProviders',
'dedupe_default_limit',
'esm_loader',
'prevNextBackend',
'import_batch_size',
]);
Expand Down
4 changes: 2 additions & 2 deletions CRM/Core/Region.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public function render($default, $allowCmsOverride = TRUE) {
case 'scriptUrl':
// ECMAScript Modules (ESMs) are basically Javascript files, but they require a slightly different incantation.
if (!empty($snippet['esm'])) {
$html .= sprintf("<script type=\"module\" src=\"%s\">\n</script>\n", $snippet['scriptUrl']);
$html .= Civi::service('esm.loader')->renderModule($snippet);
}
elseif (!$allowCmsOverride || !$cms->addScriptUrl($snippet['scriptUrl'], $this->_name)) {
$html .= sprintf("<script type=\"text/javascript\" src=\"%s\">\n</script>\n", $snippet['scriptUrl']);
Expand All @@ -113,7 +113,7 @@ public function render($default, $allowCmsOverride = TRUE) {
case 'script':
// ECMAScript Modules (ESMs) are basically Javascript files, but they require a slightly different incantation.
if (!empty($snippet['esm'])) {
$html .= sprintf("<script type=\"module\">\n%s\n</script>\n", $snippet['script']);
$html .= Civi::service('esm.loader')->renderModule($snippet);
}
elseif (!$allowCmsOverride || !$cms->addScript($snippet['script'], $this->_name)) {
$html .= sprintf("<script type=\"text/javascript\">\n%s\n</script>\n", $snippet['script']);
Expand Down
5 changes: 5 additions & 0 deletions CRM/Core/Resources/CollectionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
+--------------------------------------------------------------------+
*/

use Civi\Core\Event\GenericHookEvent;

/**
* Class CRM_Core_Resources_CollectionTrait
*
Expand Down Expand Up @@ -101,6 +103,9 @@ public function add($snippet) {
break;
}
}
if (!empty($snippet['esm'])) {
Civi::dispatcher()->dispatch('civi.esm.useModule', GenericHookEvent::create(['snippet' => &$snippet]));
}

if ($snippet['type'] === 'scriptFile' && !isset($snippet['scriptFileUrls'])) {
$res = Civi::resources();
Expand Down
29 changes: 29 additions & 0 deletions CRM/Utils/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -2841,6 +2841,35 @@ public static function entityRefFilters(&$filters, &$links = NULL) {
);
}

/**
* Build a list of ECMAScript Modules (ESM's) that are available for auto-loading.
*
* Subscribers should assume that the $importMap will be cached and re-used.
*
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
* @link https://github.com/WICG/import-maps
* @see \Civi\Esm\ImportMap
*
* @param array $importMap
* Ex: ['imports' => ['square/' => 'https://example.com/square/']]
*
* This data-structure is defined by the browser-vendors. In the future, browser-vendors
* may update the supported features. It is the subscribers' responsibility to conform
* with browser standards.
* @param array $context
* In the future, the `$context` may provide hints about the usage environment. Based
* on these hints, you may omit unnecessary mappings. However, in the absence of a clear
* hint, listeners should tend to over-communicate (i.e. report all the mappings that
* you can).
*/
public static function esmImportMap(array &$importMap, array $context): void {
$null = NULL;
self::singleton()->invoke(['importMap', 'context'], $importMap, $context, $null,
$null, $null, $null,
'civicrm_esmImportMap'
);
}

/**
* This hook is called for bypass a few civicrm urls from IDS check.
*
Expand Down
27 changes: 27 additions & 0 deletions Civi/Core/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Civi\Core\Compiler\EventScannerPass;
use Civi\Core\Compiler\SpecProviderPass;
use Civi\Core\Event\EventScanner;
use Civi\Core\Event\GenericHookEvent;
use Civi\Core\Lock\LockManager;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
Expand Down Expand Up @@ -392,6 +393,11 @@ public function createContainer() {
[new Reference('action_object_provider')]
);

$container->setDefinition('esm.loader', new Definition(
'object',
[new Reference('service_container')]
))->setFactory([new Reference(self::SELF), 'createEsmLoader'])->setPublic(TRUE);

\CRM_Utils_Hook::container($container);

return $container;
Expand Down Expand Up @@ -574,6 +580,27 @@ public static function createPrevNextCache($container) {
return $container->get('prevnext.driver.' . $setting);
}

/**
* Determine which component will load ECMAScript Modules.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* @return \object
*/
public static function createEsmLoader($container): object {
$name = \Civi::settings()->get('esm_loader');
if ($name === 'auto') {
$name = 'shim';
\Civi::dispatcher()->dispatch('civi.esm.loader.default', GenericHookEvent::create(['default' => &$name]));
}
if ($container->has("esm.loader.$name")) {
return $container->get("esm.loader.$name");
}
else {
\Civi::log()->warning('Invalid ESM loader: {name}', ['name' => $name]);
return $container->get("esm.loader.browser");
}
}

/**
* @return \ArrayObject
*/
Expand Down
139 changes: 139 additions & 0 deletions Civi/Esm/BasicLoaderTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace Civi\Esm;

use Civi;

/**
* The AbstractLoader is a base-class BrowserLoader and ShimLoader. These are similar
* in that they load ESM's by displaying HTML, e.g.
*
* <script type="importmap">
* { "import": {"civicrm/": "https://example.com/sites/all/modules/civicrm"}}
* </script>
*
* <script type="module">
* import { TableWidget } from "civicrm/TableWidget.js";
* const table = new TableWidget();
* </script>
*
* However, subclasses may use different HTML.
*/
trait BasicLoaderTrait {

/**
* @return array
*/
public static function getSubscribedEvents() {
return [
'&civi.esm.useModule' => 'onUseModule',
'&civi.region.render' => 'onRegionRender',
];
}

/**
* @var \Civi\Esm\ImportMap
* @inject esm.import_map
*/
protected $importMap;

/**
* Do we need to send an import-map for the current page-view?
*
* For the moment, we figure this dynamically -- based on whether any "esm" scripts have
* been added. During the early stages (where ESMs aren't in widespread use), this seems
* safer. However, in the future, we might find some kind of race (e.g. where the system
* renders "<head>" before it decides on a specific "<script type=module"> to load.
* If that edge-case happens, then it's probably fair to switch this default
* (`$required=TRUE`).
*
* @var bool
*/
protected $required = FALSE;

/**
* Receive a notification that an ESM is being used.
*
* @param array $snippet
* The module resource being rendered, as per "CollectionInterface::add()".
* Ex: ['type' => 'scriptUrl', 'scriptUrl' => 'https://example.com/foo.js', 'esm' => TRUE, 'region' => 'page-footer']
* @see \CRM_Core_Resources_CollectionTrait::add()
*/
public function onUseModule(array &$snippet): void {
$this->required = TRUE;
}

/**
* Listen to 'civi.region.render[html-header]'.
*
* If there are any active "module"s on this page, then output the "import-map".
*
* @param \CRM_Core_Region $region
*/
public function onRegionRender(\CRM_Core_Region $region): void {
if ($region->_name !== 'html-header' || !$this->required || $this !== Civi::service('esm.loader')) {
return;
}

$importMap = $this->importMap->get();
$region->add([
'name' => 'import-map',
'markup' => empty($importMap) ? '' : $this->renderImportMap($importMap),
'weight' => -1000,
]);
}

/**
* Format the list of imports as an HTML tag.
*
* @param array $importMap
* Ex: ['imports' => ['square/' => 'https://example.com/square/']]
* @return string
* Ex: '<script type="importmap">{"imports": ...}</script>'
*/
abstract protected function renderImportMap(array $importMap): string;

/**
* @param array $snippet
* The module resource being rendered, as per "CollectionInterface::add()".
* Ex: ['type' => 'scriptUrl', 'scriptUrl' => 'https://example.com/foo.js', 'esm' => TRUE]
* @return string
* HTML
* @see \CRM_Core_Resources_CollectionInterface::add()
*/
public function renderModule(array $snippet): string {
switch ($snippet['type']) {
case 'script':
return $this->renderModuleScript($snippet);

case 'scriptUrl':
return $this->renderModuleUrl($snippet);

default:
$class = get_class($this);
Civi::log()->warning($class . ' does not support {type}', ['type' => $snippet['type']]);
return '';
}
}

/**
* @param array $snippet
* The module resource being rendered, as per "CollectionInterface::add()".
* Ex: ['type' => 'scriptUrl', 'scriptUrl' => 'https://example.com/foo.js', 'esm' => TRUE]
* @return string
* HTML
* @see \CRM_Core_Resources_CollectionInterface::add()
*/
abstract protected function renderModuleScript(array $snippet): string;

/**
* @param array $snippet
* The module resource being rendered, as per "CollectionInterface::add()".
* Ex: ['type' => 'scriptUrl', 'scriptUrl' => 'https://example.com/foo.js', 'esm' => TRUE]
* @return string
* HTML
* @see \CRM_Core_Resources_CollectionInterface::add()
*/
abstract protected function renderModuleUrl(array $snippet): string;

}
58 changes: 58 additions & 0 deletions Civi/Esm/BrowserLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace Civi\Esm;

use Civi;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* The BrowserLoader leverages the browser's built-in support for ECMAScript Modules (ESM's).
*
* Any ESM's required by CiviCRM or its extensions are rendered like so:
*
* <script type="importmap">
* { "import": {"civicrm/": "https://example.com/sites/all/modules/civicrm"}}
* </script>
* <script type="module">
* import { TableWidget } from "civicrm/TableWidget.js";
* const table = new TableWidget();
* </script>
*
* This should be the simplest and most efficient way to load modules. However, there may be
* compatibility issues with older browsers or future UFs.
*
* For a fuller description of this mechanism, see the neighboring README.
* @see ./README.md
*
* @service esm.loader.browser
*/
class BrowserLoader extends \Civi\Core\Service\AutoService implements EventSubscriberInterface {

use BasicLoaderTrait;

/**
* @inheritDoc
*/
protected function renderImportMap(array $importMap): string {
$flags = JSON_UNESCAPED_SLASHES;
if (Civi::settings()->get('debug_enabled')) {
$flags |= JSON_PRETTY_PRINT;
}
return sprintf("<script type='importmap'>\n%s\n</script>", json_encode($importMap, $flags));
}

/**
* @inheritDoc
*/
protected function renderModuleScript(array $snippet): string {
return sprintf("<script type=\"module\">\n%s\n</script>\n", $snippet['script']);
}

/**
* @inheritDoc
*/
protected function renderModuleUrl(array $snippet): string {
return sprintf("<script type=\"module\" src=\"%s\">\n</script>\n", $snippet['scriptUrl']);
}

}
Loading