Add support for loading ECMAScript Modules (ESM)
Introduction
-------------
There was [a recent announcement](https://web.dev/import-maps-in-all-modern-browsers/) about a milestone -- all major browsers [now support](https://caniuse.com/import-maps) ECMAScript Modules (ESM) with "import-maps". Import-maps make ESM viable for complex applications (like CiviCRM) without requiring NodeJS during deployment.
The aim of this issue is to fully support ESM for JS development in CiviCRM -- by leveraging browser support for import-maps. It is broken down into a few sections:
* Part 1: Basic ESM Support
* Part 2: Import Map Support
However, because this is relatively new functionality with some risk and compatibility issues, the approach described here is an incremental phase-in -- where specific files/pages/extensions may use ESM (without requiring it for existing screens).
Background
----------
* As discussed in https://gist.github.com/totten/5c34e3885a4fe7002f990e09395b4294, one major factor in updating CiviCRM's Javascript tooling is support for ECMAScript Modules (ESM).
* ESM has increasingly become necessary for a wide of range Javascript tools and libraries.
* Originally, browsers did not support ESM. Instead, JS developers used NodeJS-based tools (such as Webpack or Rollup) to convert ESM's into basic Javascript. However, browser support for ESM has improved over the years.
* For a decent introduction to the concepts of ESM and its import-maps, see (e.g.) https://blog.logrocket.com/es-modules-in-browsers-with-import-maps/ (**Reading this #4279 will be easier if you've already been introduced to the concepts.**)
* This issue corresponds to [approach 4 in the gist](https://gist.github.com/totten/5c34e3885a4fe7002f990e09395b4294#approaches-to-linkingloading-es6-for-civicrm), except that (now) Firefox and Safari both have native support for import-maps.
Part 1. Basic ESM Support
--------------------------------
CiviCRM currently allows you to add Javascript to a page. For example:
```php
Civi::resources()->addScript('doStuff();');
Civi::resources()->addScriptFile('my_extension', 'js/foo.js');
Civi::resources()->addScriptUrl('https://example.com/bar.js');
```
generates output like:
```html
<script type="text/javascript">
doStuff();
</script>
<script type="text/javascript" src="https://example.com/path/to/my_extension/js/foo.js"></script>
<script type="text/javascript" src="https://example.com/bar.js"></script>
```
ESM documents are slightly different from regular Javascript documents -- e.g. they support the `import` and `export` keywords. Browsers require a different declaration for ESM:
```html
<script type="module">
import doStuff from './do-stuff.js';
doStuff();
</script>
<script type="module" src="https://example.com/path/to/my_extension/js/foo.js"></script>
<script type="module" src="https://example.com/bar.js"></script>
```
On the PHP side, we probably need a way to flag resources as ESMs. Perhaps:
```php
Civi::resources()->addScript('import doStuff from \'./do-stuff.js\'; doStuff();', ['esm' => TRUE]);
Civi::resources()->addScriptFile('my_extension', 'js/foo.js', ['esm' => TRUE]);
Civi::resources()->addScriptUrl('https://example.com/bar.js', ['esm' => TRUE]);
```
or
```php
Civi::resources()->addModule('import doStuff from \'./do-stuff.js\'; doStuff();');
Civi::resources()->addModuleFile('my_extension', 'js/foo.js');
Civi::resources()->addModuleUrl('https://example.com/bar.js');
```
Other considerations:
* Does it matter if or how ESM's appear in `hook_civicrm_coreResourceList`? Assuming we keep our current file-names (`*.js`), then there'd be no way to distinguish vanilla JS files from ESM files. OTOH, that hook was [deprecated circa 5.31](https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_coreResourceList/). The newer `hook_civicrm_alterBundle` should be more agreeable (e.g. allowing `$bundle->addModuleFile()`, `$bundle->filter()`, etc).
* Is it absolutely necessary to add more methods or flags to `Civi::resources()`? Hypothetically, we could do a global swap in all generated HTML (replace `<script type="text/javascript">` with `<script type="module">`). However, [there are differences of interpretation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_standard_scripts) - e.g. wrt scoping and global variables. I did a quick hack to use `type="module"` globally... and the page didn't render. Nothing's impossible, but changing all-at-once feels like a heavier lift.
Part 2. Import Map Support
-----------------------
Outputting `<script type="module">` is a prerequisite for using ESM's in Civi. However, it's not sufficient - a CiviCRM deployment is split across various folders (*what with core, core-exts, downloaded-ext's, CMS plugins, and so on*). With basic ESM's, it's tricky to reference all these folders.
By adding an import-map, we can load dependencies from all these different folders.
For example, on a site with `civicrm`, `search_kit`, and `mosaico`, you might generate an import-map that looks like this:
```html
<script type="importmap">
{
"imports": {
"civicrm/": "https://example.com/modules/civicrm/js/",
"search_kit/": "https://example.com/modules/civicrm/ext/search_kit/js/"
"mosaico/": "https://example.com/files/civicrm/ext/mosaico/js"
}
}
</script>
```
which would enable imports from those paths, as in
```javascript
import searchFoo from 'search_kit/foo.js';
import mosaicoFoo from 'mosaico/foo.js';
```
There are a few important considerations here. In no particular order...
* [Quote](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap): "Only the first import map in the document with an inline definition is processed; any additional import maps and external import maps are ignored." -- This means that the importmap will (eventually) be subject to contention among applications like CiviCRM, Drupal, WordPress, etc. We'll (eventually) need a way to integrate the import-maps from different applications.
* AFAICS, the CMS's do not currently have a standard way to handle import-maps. But it's on their radar. (Ex: [Drupal issue](https://www.drupal.org/project/drupal/issues/3331393), [WP/Gutenberg issue](https://github.com/WordPress/gutenberg/issues/36716)) We should expect that (eventually) some CMS's will define their own registry. Even when they don't, we should expect *other* CMS plugins will start adding import-maps.
* There is a _logical namespace_. We need some guideline for organizing in the logical-namespace.
* Example: Map between CiviCRM extension-names and ESM folders (as in the `search_kit` and `mosaico` items above).
* Given that every page is limited to one import-map, that means the _logical namespace_ will be shared between Civi extensions and `$open_ended_future_cms_stuff`. We should probably use a prefix.
* Should we automatically map all extensions -- or require an opt-in (e.g. `hook_civicrm_esm` or `hook_civicrm_importMaps`)? How confident are we that CiviCRM extensions should be mapped 1-to-1 with ESM's folder namespace?
* "The `src`, `async`, ... attributes must not be specified." ([source](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap)) -- The entire importmap must be outputted inline as part of the top-level HTML page; you can load an external map (`<script type="importmap" src="url">`). However, this should eventually change as [WICG's specs define define a way to move the importmap to a `src`](https://github.com/WICG/import-maps#import-map-processing).
Given the constraints and expectation of future change, this approach comes to mind:
1. Define `hook_civicrm_esmImportMap(array &$importMap)` as a way for CiviCRM (core/exts) to modify the import-map.
2. Define `mixin/esm@1.0.0`. This is our first-best-guess at how to map between extensions and ESM folders, Maybe:
```php
return function ($mixInfo) {
Civi::dispatcher()->addListner('hook_civicrm_esmImportMap', function (&$importMap) use ($mixInfo) {
$logicalPath = 'civicrm/' . $mixInfo->shortName . '/';
$physicalPath = Civi::resources()->getUrl($mixInfo->longName) . '/';
$importMap[$logicalPath] = $physicalPath;
});
}
```
3. Provide an initial implementation which fires `hook_esmImportMap` and outputs the `<script type="importmap">...</script>` in the page-header.
* However, we should expect that (in the future) UF-integrations will need to swap this -- they will instead ask CiviCRM for `$importMap` and pass that to a CMS API.
* Additionally, if this mechanism is swappable, then that should improve the prognosis for adding the [es-module-shims](https://github.com/guybedford/es-module-shims) to extend compatibility to older browsers.
issue