From 55f86dde39c9ec81e57eae4b50e6d2949ce910e8 Mon Sep 17 00:00:00 2001 From: meirzamoodle Date: Sun, 1 Mar 2026 07:34:19 +0700 Subject: [PATCH] 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// - 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) --- docs/guides/javascript/react/importmap.md | 222 ++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/guides/javascript/react/importmap.md diff --git a/docs/guides/javascript/react/importmap.md b/docs/guides/javascript/react/importmap.md new file mode 100644 index 0000000000..c1953adb2b --- /dev/null +++ b/docs/guides/javascript/react/importmap.md @@ -0,0 +1,222 @@ +--- +title: Aliasing and Import Maps +tags: + - Javascript + - ESM + - React + - Import Maps +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. +--- + +Moodle uses native browser import maps as the mechanism for resolving bare module specifiers +(for example `react` or `@moodle/lms/`) to real URLs at runtime. This replaces the need for +bundler-specific alias configuration and allows Moodle components to write standard ESM +`import` statements that work directly in the browser. + +## What is an Import Map? + +An import map is a JSON object, embedded in the page as a ` +``` + +With this map in place, any ES module on the page can write: + +```js +import React from 'react'; +import { jsx } from 'react/jsx-runtime'; +import { someUtil } from '@moodle/lms/core/utils'; +``` + +…and the browser resolves the specifier to the correct URL without any bundler step at runtime. + +## How Moodle generates the Import Map + +The import map is built and injected into the page automatically by +`page_requirements_manager::get_import_map()`, which is called during the page `` +render phase. + +### The `import_map` class + +**`core\output\requirements\import_map`** is the single source of truth for all +specifier → URL mappings and specifier → filesystem path mappings. It implements +`JsonSerializable` so it can be written directly into the page as JSON, and it is also +consulted by the ESM controller when serving files. + +Key responsibilities: + +- Holds a list of specifier → entry mappings. +- Accepts a **default loader URL** (the ESM serving endpoint) which is used to derive + concrete URLs for entries that use the default loader. +- Provides `add_import()` to register additional specifiers, or to override the built-in + ones, from a `pre_render` hook. + +#### Built-in specifiers + +The following specifiers are registered by default in `add_standard_imports()`: + +| Specifier | URL in import map | Filesystem path | +|---|---|---| +| `@moodle/lms/` | `{loaderBase}@moodle/lms/` | Component's `js/esm/build/` directory | +| `@moodlehq/design-system` | `{loaderBase}@moodlehq/design-system` | `lib/js/bundles/design-system.js` | +| `react` | `{loaderBase}react` | `lib/js/bundles/react/react.js` | +| `react/` | `{loaderBase}react/` | `lib/js/bundles/react/` (prefix) | +| `react-dom` | `{loaderBase}react-dom` | `lib/js/bundles/react-dom/react-dom.js` | + +The `react/` prefix entry covers all sub-specifiers such as `react/jsx-runtime`. + +:::note +`{loaderBase}` is the base URL of the ESM serving endpoint — the URL that +`page_requirements_manager::get_import_map()` sets as the default loader. In practice it looks like +`https://example.com/esm/12345/`, where `12345` is the JS revision number returned by +`page_requirements_manager::get_jsrev()`. All specifier URLs in the import map are built by +appending the bare specifier to this base URL. + +The revision value `/esm/-1/` means the revision is invalid or in development mode. When the +revision is `-1`, the ESM controller applies short-lived cache headers so the browser re-fetches +the file on every page load instead of serving a stale cached copy. +::: + +#### Adding a custom specifier + +You can extend the import map from a `pre_render` hook before the page is rendered: + +```php +use core\output\requirements\import_map; + +// Fetch the shared singleton from the DI container. +$importmap = \core\di::get(import_map::class); + +// Map 'my-lib' to an absolute URL (e.g. a CDN). Used verbatim in the import map. +$importmap->add_import('my-lib', loader: new \core\url('https://cdn.example.com/my-lib.js')); + +// Map '@myplugin/' using a filesystem path relative to $CFG->root. +// The URL in the import map will be '{loaderBase}@myplugin/'. +// The ESM controller uses $path to locate the file on disk. +$importmap->add_import('@myplugin/', path: 'local_myplugin/js/esm/build'); +``` + +The `add_import()` signature is: + +```php +public function add_import( + string $specifier, + ?\core\url $loader = null, + ?string $path = null, + bool $loadfromcomponent = false, +): void +``` + +- **`$specifier`** — The bare specifier string used in `import` statements + (e.g. `react`, `@moodle/lms/`). +- **`$loader`** — An absolute `\core\url`. When provided, this URL is written directly + into the import map and `$path` is ignored. +- **`$path`** — A filesystem path relative to `$CFG->root`, used by the ESM controller + to locate the file on disk. Has no effect on the URL written into the import map + (the URL always uses `{loaderBase}{specifier}`). +- **`$loadfromcomponent`** — When `true`, the specifier is treated as a + `/` prefix and resolved to the component's `js/esm/build/` + directory on disk. Used internally for `@moodle/lms/`. + +## The ESM serving endpoint + +All ESM files are served by `core\route\controller\esm_controller::serve`, registered +under the route: + +``` +/esm/{revision:[0-9-]+}/{scriptpath:.*} +``` + +The controller is intentionally thin: it delegates all resolution to the `import_map` +registry, which is the single source of truth. For a given `scriptpath` it calls +`import_map::get_path_for_script()`, which: + +1. Sorts entries longest-key-first so a more-specific prefix wins + (e.g. `react/` is matched before `react`). +2. Finds the first entry whose specifier is a prefix of the requested path. +3. Returns the absolute filesystem path to the file, or `null` if no entry matches. + +If the resolved path exists on disk the file is served; otherwise a 404 is returned. + +### Resolving component modules + +Entries registered with `$loadfromcomponent = true` (i.e. `@moodle/lms/`) are resolved +differently: the path after the prefix is split into `/`, the component +directory is looked up via `core\component`, and the file is resolved to: + +``` +/js/esm/build/.js +``` + +For example, `@moodle/lms/mod_book/viewer` resolves to +`/mod/book/js/esm/build/viewer.js`. + +## HTTP caching + +The controller applies the following caching strategy: + +| Revision | Behaviour | +|---|---| +| Valid (positive integer) | Long-lived immutable cache headers + ETag. Returns `304 Not Modified` when the client already has the file cached. | +| `-1` (development / invalid) | Short-lived cache headers. Forces the browser to re-fetch on every page load. | + +The revision value comes from `page_requirements_manager::get_jsrev()` and changes +whenever JavaScript files are updated, automatically busting caches. + +## Writing a component React module + +To expose a React module through the import map: + +1. Build your TypeScript/React source (`.ts` or `.tsx`) into a compiled JavaScript file + at `/js/esm/build/.js` using the Moodle build tools. + See the React build tooling guide for details. +2. Import it in browser code using the `@moodle/lms/` scope: + +```js +import { BookViewer } from '@moodle/lms/mod_book/viewer'; +``` + +The import map translates `@moodle/lms/mod_book/viewer` → the ESM endpoint URL for +`@moodle/lms/mod_book/viewer`, which the controller resolves to +`/mod/book/js/esm/build/viewer.js`. + +:::note +The `@moodle/lms/` specifier ends with a trailing slash. This is the standard import map +convention for **package scopes**: any import that begins with `@moodle/lms/` is prefixed +with the loader base URL, allowing the entire namespace to be served from one endpoint +without registering each module individually. +::: + +## Overriding a built-in specifier + +You can replace any of the default specifiers in a `pre_render` hook. For example, to swap +in a local React build during development: + +```php +$importmap = \core\di::get(\core\output\requirements\import_map::class); +$importmap->add_import( + 'react', + loader: new \core\url('/local/devtools/react-debug.js'), +); +``` + +Calling `add_import()` with the same specifier twice overwrites the previous entry, so +ordering matters when multiple hooks are involved. + +## See also + +- [JavaScript Modules](../modules.md) — AMD and ESM module authoring in Moodle. +- [MDN: Import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) +- [HTML spec: import maps](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps)