Skip to content

Commit 55f86dd

Browse files
committed
MDL-87922 [docs] Add guide for JavaScript import maps in Moodle
Adds a new documentation page explaining how Moodle uses native browser import maps to resolve bare module specifiers (e.g. react, @moodle/lms/) to real URLs at runtime. The guide covers: - Import map basics and runtime specifier resolution - How page_requirements_manager builds/injects the map - core\output\requirements\import_map responsibilities and built-in specifiers (@moodle/lms/, @moodlehq/design-system, react, react/, react-dom) - Registering custom/plugin specifiers via pre_render hooks - ESM endpoint routing and resolution behavior (including longest-prefix matching) - Component module resolution for @moodle/lms/<component>/<module> - Cache strategy for revisioned assets vs revision -1 development mode - How to expose component React modules through js/esm/build - Overriding built-in specifiers (e.g. custom React build)
1 parent 9448879 commit 55f86dd

1 file changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
{
24+
"imports": {
25+
"@moodle/lms/": "https://example.com/esm/-1/@moodle/lms/",
26+
"@moodlehq/design-system": "https://example.com/esm/-1/@moodlehq/design-system",
27+
"react": "https://example.com/esm/-1/react",
28+
"react/": "https://example.com/esm/-1/react/",
29+
"react-dom": "https://example.com/esm/-1/react-dom"
30+
}
31+
}
32+
</script>
33+
```
34+
35+
With this map in place, any ES module on the page can write:
36+
37+
```js
38+
import React from 'react';
39+
import { jsx } from 'react/jsx-runtime';
40+
import { someUtil } from '@moodle/lms/core/utils';
41+
```
42+
43+
…and the browser resolves the specifier to the correct URL without any bundler step at runtime.
44+
45+
## How Moodle generates the Import Map
46+
47+
The import map is built and injected into the page automatically by
48+
`page_requirements_manager::get_import_map()`, which is called during the page `<head>`
49+
render phase.
50+
51+
### The `import_map` class
52+
53+
**`core\output\requirements\import_map`** is the single source of truth for all
54+
specifier → URL mappings and specifier → filesystem path mappings. It implements
55+
`JsonSerializable` so it can be written directly into the page as JSON, and it is also
56+
consulted by the ESM controller when serving files.
57+
58+
Key responsibilities:
59+
60+
- Holds a list of specifier → entry mappings.
61+
- Accepts a **default loader URL** (the ESM serving endpoint) which is used to derive
62+
concrete URLs for entries that use the default loader.
63+
- Provides `add_import()` to register additional specifiers, or to override the built-in
64+
ones, from a `pre_render` hook.
65+
66+
#### Built-in specifiers
67+
68+
The following specifiers are registered by default in `add_standard_imports()`:
69+
70+
| Specifier | URL in import map | Filesystem path |
71+
|---|---|---|
72+
| `@moodle/lms/` | `{loaderBase}@moodle/lms/` | Component's `js/esm/build/` directory |
73+
| `@moodlehq/design-system` | `{loaderBase}@moodlehq/design-system` | `lib/js/bundles/design-system.js` |
74+
| `react` | `{loaderBase}react` | `lib/js/bundles/react/react.js` |
75+
| `react/` | `{loaderBase}react/` | `lib/js/bundles/react/` (prefix) |
76+
| `react-dom` | `{loaderBase}react-dom` | `lib/js/bundles/react-dom/react-dom.js` |
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

Comments
 (0)