From e60ccdb11dbbbedf1a8b3fbe3c79c54bfbc5654e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 11 May 2023 00:57:27 -0700 Subject: [PATCH] (dev/core#4279) Define import-maps for ECMAScript Modules --- CRM/Core/Resources/CollectionTrait.php | 3 + CRM/Utils/Hook.php | 28 ++++ Civi/Core/ImportMap.php | 198 +++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 Civi/Core/ImportMap.php diff --git a/CRM/Core/Resources/CollectionTrait.php b/CRM/Core/Resources/CollectionTrait.php index dfda8e4518..a4c0421204 100644 --- a/CRM/Core/Resources/CollectionTrait.php +++ b/CRM/Core/Resources/CollectionTrait.php @@ -101,6 +101,9 @@ trait CRM_Core_Resources_CollectionTrait { break; } } + if (!empty($snippet['esm'])) { + Civi::service('import_map')->setRequired(TRUE); + } if ($snippet['type'] === 'scriptFile' && !isset($snippet['scriptFileUrls'])) { $res = Civi::resources(); diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index 9119a0810b..a0b7ec25ab 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -2841,6 +2841,34 @@ abstract class CRM_Utils_Hook { ); } + /** + * 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\Core\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. * diff --git a/Civi/Core/ImportMap.php b/Civi/Core/ImportMap.php new file mode 100644 index 0000000000..bbebe1296c --- /dev/null +++ b/Civi/Core/ImportMap.php @@ -0,0 +1,198 @@ + + * { "import": {"civicrm/": "https://example.com/sites/all/modules/civicrm"}} + * + * + * This service defines the import-map for CiviCRM and its extensions. There are a few perspectives + * on how to use this service. + * + * ################################################################################### + * ## Extension Developer: How to register additional mappings + * + * If you are writing Javascript code for an extension, then you may want to define new mappings, e.g. + * + * function myext_civicrm_esmImportMap(array &$importMap, array $context) { + * $importMap['imports']['foo/'] = E::url('js/foo/'); + * $importMap['imports']['bar/'] = E::url('packages/bar/dist/'); + * } + * + * ################################################################################### + * ## Core Developer: How to render a default SCRIPT tag + * + * CiviCRM must generate the SCRIPT tag because (at time of writing) none of the supported UF's have + * a mechanism to do so. + * + * - Logic: IF the current page has any ESM modules, THEN display a SCRIPT in the `html-header`. + * + * - Implementation: The import-map listens to the `civi.region.render[html-header]` event. Whenever + * the header is generated, it makes a dynamic decision about whether to display. + * + * ################################################################################### + * ## UF/CMS Developer: How to integrate Civi's import-map into the UF/CMS import-map. + * + * In the future, UFs may define their own protocols for generating their own import-maps. + * But the browser can only load one import-map. Therefore, the CiviCRM and UF import-maps + * will need to be integrated. Here is how: + * + * 1. Disable CiviCRM's default SCRIPT renderer: + * + * Civi::dispatcher()->addListener('hook_config', fn($e) => Civi::service('import_map')->setAutoInject(FALSE)); + * + * (*You might do this in `CRM_Utils_System_*::initialize()`*) + * + * 2. Get the import-map from CiviCRM: + * + * $importMap = Civi::service('import_map')->get(); + * + * 3. Pass the $importMap to the UF's native API (with any required transformations). + * + * @service import_map + */ +class ImportMap extends \Civi\Core\Service\AutoService implements HookInterface { + + /** + * 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 "" before it decides on a specific "' + */ + public function render(array $importMap): string { + if (empty($importMap)) { + return ''; + } + else { + $flags = JSON_UNESCAPED_SLASHES; + if (\CRM_Core_Config::singleton()->debug) { + $flags |= JSON_PRETTY_PRINT; + } + return sprintf("", json_encode($importMap, $flags)); + } + } + +} -- 2.25.1