|
| 1 | +--- |
| 2 | +title: Aliasing and Import Maps |
| 3 | +tags: |
| 4 | + - Javascript |
| 5 | + - ESM |
| 6 | + - React |
| 7 | + - Import Maps |
| 8 | +description: How Moodle uses native browser import maps to resolve bare module specifiers to real URLs at runtime, including built-in specifiers, custom entries, and the ESM serving endpoint. |
| 9 | +--- |
| 10 | + |
| 11 | +Moodle uses native browser import maps as the mechanism for resolving bare module specifiers |
| 12 | +(for example `react` or `@moodle/lms/`) to real URLs at runtime. This replaces the need for |
| 13 | +bundler-specific alias configuration and allows Moodle components to write standard ESM |
| 14 | +`import` statements that work directly in the browser. |
| 15 | + |
| 16 | +## What is an Import Map? |
| 17 | + |
| 18 | +An import map is a JSON object, embedded in the page as a `<script type="importmap">` tag, |
| 19 | +that tells the browser how to resolve bare specifiers used in `import` statements. |
| 20 | + |
| 21 | +```html |
| 22 | +<script type="importmap">{ |
| 23 | + "imports": { |
| 24 | + "@moodle/lms/": "http://localhost/MDL-87922/r.php/core/esm/-1/@moodle/lms/", |
| 25 | + "@moodlehq/design-system": "http://localhost/MDL-87922/r.php/core/esm/-1/@moodlehq/design-system", |
| 26 | + "react": "http://localhost/MDL-87922/r.php/core/esm/-1/react", |
| 27 | + "react/": "http://localhost/MDL-87922/r.php/core/esm/-1/react/", |
| 28 | + "react-dom": "http://localhost/MDL-87922/r.php/core/esm/-1/react-dom", |
| 29 | + "react-dom/": "http://localhost/MDL-87922/r.php/core/esm/-1/react-dom/" |
| 30 | + } |
| 31 | +}</script> |
| 32 | +``` |
| 33 | + |
| 34 | +With this map in place, any ES module on the page can write: |
| 35 | + |
| 36 | +```js |
| 37 | +import React from 'react'; |
| 38 | +import { jsx } from 'react/jsx-runtime'; |
| 39 | +import { someUtil } from '@moodle/lms/core/utils'; |
| 40 | +``` |
| 41 | + |
| 42 | +…and the browser resolves the specifier to the correct URL without any bundler step at runtime. |
| 43 | + |
| 44 | +## How Moodle generates the Import Map |
| 45 | + |
| 46 | +The import map is built and injected into the page automatically by |
| 47 | +`page_requirements_manager::get_import_map()`, which is called during the page `<head>` |
| 48 | +render phase. |
| 49 | + |
| 50 | +### The `import_map` class |
| 51 | + |
| 52 | +**`core\output\requirements\import_map`** is the single source of truth for all |
| 53 | +specifier → URL mappings and specifier → filesystem path mappings. It implements |
| 54 | +`JsonSerializable` so it can be written directly into the page as JSON, and it is also |
| 55 | +consulted by the ESM controller when serving files. |
| 56 | + |
| 57 | +Key responsibilities: |
| 58 | + |
| 59 | +- Holds a list of specifier → entry mappings. |
| 60 | +- Accepts a **default loader URL** (the ESM serving endpoint) which is used to derive |
| 61 | + concrete URLs for entries that use the default loader. |
| 62 | +- Provides `add_import()` to register additional specifiers, or to override the built-in |
| 63 | + ones, from a `pre_render` hook. |
| 64 | + |
| 65 | +#### Built-in specifiers |
| 66 | + |
| 67 | +The following specifiers are registered by default in `add_standard_imports()`: |
| 68 | + |
| 69 | +| Specifier | URL in import map | Filesystem path | |
| 70 | +|---|---|---| |
| 71 | +| `@moodle/lms/` | `{loaderBase}@moodle/lms/` | Component's `js/esm/build/` directory | |
| 72 | +| `@moodlehq/design-system` | `{loaderBase}@moodlehq/design-system` | `lib/js/bundles/design-system.js` | |
| 73 | +| `react` | `{loaderBase}react` | `lib/js/bundles/react/react.js` | |
| 74 | +| `react/` | `{loaderBase}react/` | `lib/js/bundles/react/` (prefix) | |
| 75 | +| `react-dom` | `{loaderBase}react-dom` | `lib/js/bundles/react-dom/react-dom.js` | |
| 76 | +| `react-dom/` | `{loaderBase}react-dom/` | `lib/js/bundles/react-dom/` (prefix) | |
| 77 | + |
| 78 | +The `react/` prefix entry covers all sub-specifiers such as `react/jsx-runtime`. |
| 79 | + |
| 80 | +:::note |
| 81 | +`{loaderBase}` is the base URL of the ESM serving endpoint — the URL that |
| 82 | +`page_requirements_manager::get_import_map()` sets as the default loader. In practice it looks like |
| 83 | +`https://example.com/esm/12345/`, where `12345` is the JS revision number returned by |
| 84 | +`page_requirements_manager::get_jsrev()`. All specifier URLs in the import map are built by |
| 85 | +appending the bare specifier to this base URL. |
| 86 | + |
| 87 | +The revision value `/esm/-1/` means the revision is invalid or in development mode. When the |
| 88 | +revision is `-1`, the ESM controller applies short-lived cache headers so the browser re-fetches |
| 89 | +the file on every page load instead of serving a stale cached copy. |
| 90 | +::: |
| 91 | + |
| 92 | +#### Adding a custom specifier |
| 93 | + |
| 94 | +You can extend the import map from a `pre_render` hook before the page is rendered: |
| 95 | + |
| 96 | +```php |
| 97 | +use core\output\requirements\import_map; |
| 98 | + |
| 99 | +// Fetch the shared singleton from the DI container. |
| 100 | +$importmap = \core\di::get(import_map::class); |
| 101 | + |
| 102 | +// Map 'my-lib' to an absolute URL (e.g. a CDN). Used verbatim in the import map. |
| 103 | +$importmap->add_import('my-lib', loader: new \core\url('https://cdn.example.com/my-lib.js')); |
| 104 | + |
| 105 | +// Map '@myplugin/' using a filesystem path relative to $CFG->root. |
| 106 | +// The URL in the import map will be '{loaderBase}@myplugin/'. |
| 107 | +// The ESM controller uses $path to locate the file on disk. |
| 108 | +$importmap->add_import('@myplugin/', path: 'local_myplugin/js/esm/build'); |
| 109 | +``` |
| 110 | + |
| 111 | +The `add_import()` signature is: |
| 112 | + |
| 113 | +```php |
| 114 | +public function add_import( |
| 115 | + string $specifier, |
| 116 | + ?\core\url $loader = null, |
| 117 | + ?string $path = null, |
| 118 | + bool $loadfromcomponent = false, |
| 119 | +): void |
| 120 | +``` |
| 121 | + |
| 122 | +- **`$specifier`** — The bare specifier string used in `import` statements |
| 123 | + (e.g. `react`, `@moodle/lms/`). |
| 124 | +- **`$loader`** — An absolute `\core\url`. When provided, this URL is written directly |
| 125 | + into the import map and `$path` is ignored. |
| 126 | +- **`$path`** — A filesystem path relative to `$CFG->root`, used by the ESM controller |
| 127 | + to locate the file on disk. Has no effect on the URL written into the import map |
| 128 | + (the URL always uses `{loaderBase}{specifier}`). |
| 129 | +- **`$loadfromcomponent`** — When `true`, the specifier is treated as a |
| 130 | + `<component>/<module>` prefix and resolved to the component's `js/esm/build/` |
| 131 | + directory on disk. Used internally for `@moodle/lms/`. |
| 132 | + |
| 133 | +## The ESM serving endpoint |
| 134 | + |
| 135 | +All ESM files are served by `core\route\controller\esm_controller::serve`, registered |
| 136 | +under the route: |
| 137 | + |
| 138 | +``` |
| 139 | +/esm/{revision:[0-9-]+}/{scriptpath:.*} |
| 140 | +``` |
| 141 | + |
| 142 | +The controller is intentionally thin: it delegates all resolution to the `import_map` |
| 143 | +registry, which is the single source of truth. For a given `scriptpath` it calls |
| 144 | +`import_map::get_path_for_script()`, which: |
| 145 | + |
| 146 | +1. Sorts entries longest-key-first so a more-specific prefix wins |
| 147 | + (e.g. `react/` is matched before `react`). |
| 148 | +2. Finds the first entry whose specifier is a prefix of the requested path. |
| 149 | +3. Returns the absolute filesystem path to the file, or `null` if no entry matches. |
| 150 | + |
| 151 | +If the resolved path exists on disk the file is served; otherwise a 404 is returned. |
| 152 | + |
| 153 | +### Resolving component modules |
| 154 | + |
| 155 | +Entries registered with `$loadfromcomponent = true` (i.e. `@moodle/lms/`) are resolved |
| 156 | +differently: the path after the prefix is split into `<component>/<module>`, the component |
| 157 | +directory is looked up via `core\component`, and the file is resolved to: |
| 158 | + |
| 159 | +``` |
| 160 | +<component_directory>/js/esm/build/<module>.js |
| 161 | +``` |
| 162 | + |
| 163 | +For example, `@moodle/lms/mod_book/viewer` resolves to |
| 164 | +`<dirroot>/mod/book/js/esm/build/viewer.js`. |
| 165 | + |
| 166 | +## HTTP caching |
| 167 | + |
| 168 | +The controller applies the following caching strategy: |
| 169 | + |
| 170 | +| Revision | Behaviour | |
| 171 | +|---|---| |
| 172 | +| Valid (positive integer) | Long-lived immutable cache headers + ETag. Returns `304 Not Modified` when the client already has the file cached. | |
| 173 | +| `-1` (development / invalid) | Short-lived cache headers. Forces the browser to re-fetch on every page load. | |
| 174 | + |
| 175 | +The revision value comes from `page_requirements_manager::get_jsrev()` and changes |
| 176 | +whenever JavaScript files are updated, automatically busting caches. |
| 177 | + |
| 178 | +## Writing a component React module |
| 179 | + |
| 180 | +To expose a React module through the import map: |
| 181 | + |
| 182 | +1. Build your TypeScript/React source (`.ts` or `.tsx`) into a compiled JavaScript file |
| 183 | + at `<component_directory>/js/esm/build/<module>.js` using the Moodle build tools. |
| 184 | + See the React build tooling guide for details. |
| 185 | +2. Import it in browser code using the `@moodle/lms/` scope: |
| 186 | + |
| 187 | +```js |
| 188 | +import { BookViewer } from '@moodle/lms/mod_book/viewer'; |
| 189 | +``` |
| 190 | + |
| 191 | +The import map translates `@moodle/lms/mod_book/viewer` → the ESM endpoint URL for |
| 192 | +`@moodle/lms/mod_book/viewer`, which the controller resolves to |
| 193 | +`<dirroot>/mod/book/js/esm/build/viewer.js`. |
| 194 | + |
| 195 | +:::note |
| 196 | +The `@moodle/lms/` specifier ends with a trailing slash. This is the standard import map |
| 197 | +convention for **package scopes**: any import that begins with `@moodle/lms/` is prefixed |
| 198 | +with the loader base URL, allowing the entire namespace to be served from one endpoint |
| 199 | +without registering each module individually. |
| 200 | +::: |
| 201 | + |
| 202 | +## Overriding a built-in specifier |
| 203 | + |
| 204 | +You can replace any of the default specifiers in a `pre_render` hook. For example, to swap |
| 205 | +in a local React build during development: |
| 206 | + |
| 207 | +```php |
| 208 | +$importmap = \core\di::get(\core\output\requirements\import_map::class); |
| 209 | +$importmap->add_import( |
| 210 | + 'react', |
| 211 | + loader: new \core\url('/local/devtools/react-debug.js'), |
| 212 | +); |
| 213 | +``` |
| 214 | + |
| 215 | +Calling `add_import()` with the same specifier twice overwrites the previous entry, so |
| 216 | +ordering matters when multiple hooks are involved. |
| 217 | + |
| 218 | +## See also |
| 219 | + |
| 220 | +- [JavaScript Modules](../modules.md) — AMD and ESM module authoring in Moodle. |
| 221 | +- [MDN: Import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) |
| 222 | +- [HTML spec: import maps](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps) |
0 commit comments