diff --git a/README.rst b/README.rst index 391140eb..fe3fffb4 100644 --- a/README.rst +++ b/README.rst @@ -506,6 +506,82 @@ For instance: Refer to the `patch catalog <#template-patch-catalog>`_ below for more details. +Configuring External Scripts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +External scripts are a frontend-platform feature that allows script loaders to be configured via ``env.config.jsx``. A loader is a JavaScript class with a ``constructor({ config })`` and a ``loadScript()`` method. This plugin provides the ``EXTERNAL_SCRIPTS`` hook so that Tutor plugins can register loaders for MFEs without resorting to patches. + +The hook works similarly to ``PLUGIN_SLOTS``. Each item is a tuple of ``(mfe_name, loader_class)``, where ``mfe_name`` is either ``"all"`` (to apply to every MFE) or the name of a specific MFE, and ``loader_class`` is the name of a loader class that will be added to the ``externalScripts`` config array. Frontend-platform instantiates the class at runtime and passes the MFE's runtime config to its constructor. + +For instance, to load a `CookieYes `_ consent banner across all MFEs, define a loader directly in ``env.config.jsx``: + +.. code-block:: python + + from tutormfe.hooks import EXTERNAL_SCRIPTS + from tutor import hooks + + hooks.Filters.ENV_PATCHES.add_item( + ( + "mfe-env-config-buildtime-definitions", + """ + class CookieYesLoader { + constructor() {} + + loadScript() { + const script = document.createElement('script'); + script.id = 'cookieyes'; + script.src = 'https://cdn-cookieyes.com/client_data/YOUR_SITE_ID/script.js'; + document.head.appendChild(script); + } + } + """, + ) + ) + + EXTERNAL_SCRIPTS.add_items([ + ( + "all", + "CookieYesLoader", + ), + ]) + +The ``CookieYesLoader`` class is defined via the ``mfe-env-config-buildtime-definitions`` patch, and the ``EXTERNAL_SCRIPTS`` hook wires it into the configuration. In this case, the CookieYes site ID is hardcoded directly in the loader. If you need to read a value from the MFE runtime configuration instead, accept ``{ config }`` in the constructor and reference the appropriate key - the built-in ``GoogleAnalyticsLoader`` in ``@openedx/frontend-platform/scripts`` does this with ``config.GOOGLE_ANALYTICS_4_ID``, for example. You can import it with the ``mfe-env-config-buildtime-imports`` patch and use it with ``EXTERNAL_SCRIPTS`` in the same way. + +You can also target a specific MFE. For example, to load a custom script only on the learning MFE: + +.. code-block:: python + + from tutormfe.hooks import EXTERNAL_SCRIPTS + from tutor import hooks + + hooks.Filters.ENV_PATCHES.add_item( + ( + "mfe-dockerfile-post-npm-install", + """ + RUN npm install @myorg/custom-script-loader + """, + ) + ) + + hooks.Filters.ENV_PATCHES.add_item( + ( + "mfe-env-config-buildtime-imports", + """ + import { CustomScriptLoader } from '@myorg/custom-script-loader'; + """, + ) + ) + + EXTERNAL_SCRIPTS.add_items([ + ( + "learning", + "CustomScriptLoader", + ), + ]) + +Note that if no external scripts are configured, the ``externalScripts`` key is not set in the config at all, so any MFE-level defaults are preserved. + + Hosting extra static files ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/changelog.d/20260415_external_scripts.md b/changelog.d/20260415_external_scripts.md new file mode 100644 index 00000000..346f62e5 --- /dev/null +++ b/changelog.d/20260415_external_scripts.md @@ -0,0 +1 @@ +- [Feature] Add `EXTERNAL_SCRIPTS` hook for configuring MFE external scripts via `env.config.jsx`. (by @arbrandes) diff --git a/tutormfe/hooks.py b/tutormfe/hooks.py index d88c7b44..55dc432f 100644 --- a/tutormfe/hooks.py +++ b/tutormfe/hooks.py @@ -15,3 +15,5 @@ MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter() PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter() + +EXTERNAL_SCRIPTS: Filter[list[tuple[str, str]], []] = Filter() diff --git a/tutormfe/plugin.py b/tutormfe/plugin.py index 4314a6ae..35e81bb3 100644 --- a/tutormfe/plugin.py +++ b/tutormfe/plugin.py @@ -13,7 +13,7 @@ from tutor.types import Config, get_typed from .__about__ import __version__ -from .hooks import MFE_APPS, MFE_ATTRS_TYPE, PLUGIN_SLOTS +from .hooks import EXTERNAL_SCRIPTS, MFE_APPS, MFE_ATTRS_TYPE, PLUGIN_SLOTS # Handle version suffix in main mode, just like tutor core if __version_suffix__: @@ -125,6 +125,14 @@ def get_plugin_slots(mfe_name: str) -> list[tuple[str, str]]: return [i[-2:] for i in PLUGIN_SLOTS.iterate() if i[0] == mfe_name] +@tutor_hooks.lru_cache +def get_external_scripts(mfe_name: str) -> list[str]: + """ + This function is cached for performance. + """ + return [i[-1] for i in EXTERNAL_SCRIPTS.iterate() if i[0] == mfe_name] + + def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]: """ Yield: @@ -143,6 +151,15 @@ def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]: yield from get_plugin_slots(mfe_name) +def iter_external_scripts(mfe_name: str) -> t.Iterable[str]: + """ + Yield: + + (script_config) + """ + yield from get_external_scripts(mfe_name) + + def is_mfe_enabled(mfe_name: str) -> bool: return mfe_name in get_mfes() @@ -157,6 +174,7 @@ def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]: ("get_mfe", get_mfe), ("iter_mfes", iter_mfes), ("iter_plugin_slots", iter_plugin_slots), + ("iter_external_scripts", iter_external_scripts), ("is_mfe_enabled", is_mfe_enabled), ("MFEMountData", MFEMountData), ] diff --git a/tutormfe/templates/mfe/build/mfe/env.config.jsx b/tutormfe/templates/mfe/build/mfe/env.config.jsx index f2ed3c11..839fc149 100644 --- a/tutormfe/templates/mfe/build/mfe/env.config.jsx +++ b/tutormfe/templates/mfe/build/mfe/env.config.jsx @@ -11,6 +11,14 @@ function addPlugins(config, slot_name, plugins) { config.pluginSlots[slot_name].plugins.push(...plugins); } +function addExternalScripts(config, scripts) { + if (!config.externalScripts) { + config.externalScripts = []; + } + + config.externalScripts.push(...scripts); +} + {{- patch("mfe-env-config-buildtime-definitions") }} async function setConfig () { @@ -45,6 +53,18 @@ async function setConfig () { {{- patch("mfe-env-config-runtime-final") }} } catch (err) { console.error("env.config.jsx failed to apply: ", err);} + {%- for script_config in iter_external_scripts("all") %} + addExternalScripts(config, [{{ script_config }}]); + {%- endfor %} + + {%- for app_name, _ in iter_mfes() %} + if (process.env.APP_ID == '{{ app_name }}') { + {%- for script_config in iter_external_scripts(app_name) %} + addExternalScripts(config, [{{ script_config }}]); + {%- endfor %} + } + {%- endfor %} + return config; }