Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install git+https://github.com/overhangio/tutor.git@main

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll have to remember to remove this once it lands in Verawood. But without it, CI will fail.

pip install .[dev]
- name: Test lint, types, and format
run: make test
68 changes: 68 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,74 @@ Then, in your Tutor plugin, install the package and register it via the site con

This approach keeps your components, styles, and slot operations in a proper package with its own dependencies, tests, and build pipeline, rather than inlining everything through template patches.

Compat shim for FPF-built plugins
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Some Tutor plugins ship slot contributions written against the legacy ``frontend-plugin-framework`` API, and many plugin packages still import from ``@edx/frontend-platform``. To make those work on a frontend-base site without requiring each plugin to be ported first, tutor-mfe renders one compatibility module per MFE (``env.config.<mfe_name>.jsx``) and wires each one into the site as its own shim App via `@openedx/frontend-base-compat <https://github.com/openedx/frontend-base-compat>`__. The shim translates legacy slot contributions into frontend-base slot operations, and ``site/package.json`` aliases both ``@openedx/frontend-plugin-framework`` and ``@edx/frontend-platform`` as direct dependencies pointing at the shim package, so any FPF- or frontend-platform-built dependency installed transitively also resolves to the shim's drop-in stubs and neither real package is installed.

The shim does not translate ``PLUGIN_SLOTS`` contributions automatically: site operators or plugin authors choose which contributions to route through it, since not every legacy contribution survives the translation cleanly. The legacy per-MFE ``env.config.jsx`` continues to consume ``PLUGIN_SLOTS`` unchanged, so standalone legacy MFEs render the contribution exactly as before whether or not it is opted in to the shim.

The coarsest opt-in is by plugin name: ``FRONTEND_COMPAT_PLUGINS`` takes a Tutor plugin name and folds every ``PLUGIN_SLOTS`` contribution registered in that plugin's hook context into the compat shim, regardless of which legacy MFE each contribution targeted. This is useful when wrapping a third-party plugin you don't control (a meta-plugin can opt the whole thing in without touching the upstream code), or when you'd rather not enumerate every slot in your own plugin:

.. code-block:: python

from tutormfe.hooks import FRONTEND_COMPAT_PLUGINS

FRONTEND_COMPAT_PLUGINS.add_item("some-legacy-plugin")

The tradeoff is bluntness: every slot that plugin contributes goes through the shim, including any whose translation isn't covered by the default maps, so failures surface at bundle time rather than as a clear "this slot isn't supported yet" signal.

For finer control, ``FRONTEND_COMPAT_SLOTS`` opts in one ``(mfe_name, slot_name, plugin_config)`` triple at a time. Use this when you want explicit per-slot acknowledgement, when only some of a plugin's contributions translate cleanly, or when you want to ship the same contribution to legacy MFEs and the compat shim from a single source:

.. code-block:: python

from tutormfe.hooks import PLUGIN_SLOTS, FRONTEND_COMPAT_SLOTS

MY_SLOT = (
"course_outline_sidebar.v1",
"""
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'my-widget',
type: DIRECT_PLUGIN,
RenderWidget: MyComponent,
}
}
""",
)

PLUGIN_SLOTS.add_item(("learning", *MY_SLOT))
FRONTEND_COMPAT_SLOTS.add_item(("learning", *MY_SLOT))

``FRONTEND_COMPAT_SLOTS`` carries the same ``mfe_name`` field as ``PLUGIN_SLOTS``, so each contribution is routed to the per-MFE compat module rendered for that MFE and ends up in the legacy app the shim mounts for it. To target multiple MFEs from one source, register one triple per MFE. Once a plugin ships a native ``FRONTEND_SLOTS`` (or app-package) contribution for the same slot, drop the ``FRONTEND_COMPAT_SLOTS`` entry to avoid rendering the widget twice.

All compat wiring is gated on at least one ``(mfe_name, slot_name, plugin_config)`` triple actually resolving through these filters: vanilla sites, and sites whose opt-ins resolve to no contributions (e.g., a named plugin that ships no ``PLUGIN_SLOTS``), skip the shim wiring entirely and pay no overhead. MFEs with no opted-in contributions get no compat module and no legacy app on the site.

``FRONTEND_ROUTE_COMPAT_MAPS`` layers ``(mfe_id, route_roles)`` deltas on top of the shim's ``defaultRouteMap``. Use it to declare which frontend-base route roles a legacy MFE renders for, so the shim can scope a legacy app's contributions to the right routes. ``route_roles`` is a JSON-serializable list of role-id strings:

.. code-block:: python

from tutormfe.hooks import FRONTEND_ROUTE_COMPAT_MAPS

FRONTEND_ROUTE_COMPAT_MAPS.add_items([
("my-legacy-mfe", ["org.openedx.frontend.role.myCustomRole"]),
])

``FRONTEND_SLOT_COMPAT_MAPS`` and ``FRONTEND_WIDGET_COMPAT_MAPS`` follow the same shape but layer ``(legacy_id, mapping)`` deltas on top of the shim's built-in ``defaultSlotMap`` / ``defaultWidgetMap``. Use them when a plugin's ``PLUGIN_SLOTS`` contribution targets a legacy slot id that the shim doesn't yet cover, or to override a curated mapping site-locally. ``mapping`` is a JSON-serializable dict matching the shim's ``SlotMappingEntry`` / ``WidgetMappingEntry`` shape (see the shim's ADR for the full schema):

.. code-block:: python

from tutormfe.hooks import FRONTEND_SLOT_COMPAT_MAPS

FRONTEND_SLOT_COMPAT_MAPS.add_items([
("org.openedx.frontend.aspects.course_outline_sidebar.v1", {
"targetSlotId": "org.openedx.frontend.slot.aspects.outlineSidebar.v1",
}),
])

Identical contributions for the same legacy id are deduplicated silently. Divergent contributions emit a ``tutor config save`` warning and last-wins, so a transient conflict during a plugin upgrade still produces a working bundle.

Frontend-base site development
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
1 change: 1 addition & 0 deletions changelog.d/20260502_152826_adolfo_frontend_base_compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Feature] Wire FPF-built plugins into the frontend-base site via [@openedx/frontend-base-compat](https://github.com/openedx/frontend-base-compat). (by @arbrandes)
32 changes: 23 additions & 9 deletions tutormfe/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,36 @@

from tutor.core.hooks import Filter

# TODO(legacy-mfe-removal): MFE_ATTRS_TYPE, MFE_APPS, and PLUGIN_SLOTS all go
# away with the legacy MFE cleanup. See the central TODO block in plugin.py for
# the full list.
MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]
EXTERNAL_SCRIPTS: Filter[list[tuple[str, str]], []] = Filter()

FRONTEND_APP_ATTRS_TYPE = t.Dict[
t.Literal["npm_package", "npm_version", "enabled", "source"],
t.Union[str, bool],
]

MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter()

FRONTEND_APPS: Filter[dict[str, FRONTEND_APP_ATTRS_TYPE], []] = Filter()

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()
FRONTEND_SLOTS: Filter[list[str], []] = Filter()

EXTERNAL_SCRIPTS: Filter[list[tuple[str, str]], []] = Filter()
# TODO(fpf-removal): FRONTEND_COMPAT_PLUGINS, FRONTEND_COMPAT_SLOTS,
# FRONTEND_SLOT_COMPAT_MAPS, and FRONTEND_WIDGET_COMPAT_MAPS all feed the
# frontend-base compatibility shim and go away with env.config.compat.jsx
# itself.
FRONTEND_COMPAT_PLUGINS: Filter[list[str], []] = Filter()

FRONTEND_SLOTS: Filter[list[str], []] = Filter()
FRONTEND_COMPAT_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()

FRONTEND_ROUTE_COMPAT_MAPS: Filter[list[tuple[str, list[str]]], []] = Filter()

FRONTEND_SLOT_COMPAT_MAPS: Filter[list[tuple[str, dict[str, t.Any]]], []] = Filter()

FRONTEND_WIDGET_COMPAT_MAPS: Filter[list[tuple[str, dict[str, t.Any]]], []] = Filter()

# TODO(legacy-mfe-removal): MFE_ATTRS_TYPE, MFE_APPS, and PLUGIN_SLOTS all go
# away with the legacy MFE cleanup. See the central TODO block in plugin.py for
# the full list.
MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]

MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter()

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()
180 changes: 180 additions & 0 deletions tutormfe/plugin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import os
import re
import typing as t
from glob import glob

import importlib_resources
from tutor import env as tutor_env
from tutor import fmt
from tutor import hooks as tutor_hooks
from tutor.__about__ import __version_suffix__
Expand All @@ -18,7 +20,12 @@
EXTERNAL_SCRIPTS,
FRONTEND_APP_ATTRS_TYPE,
FRONTEND_APPS,
FRONTEND_COMPAT_PLUGINS,
FRONTEND_COMPAT_SLOTS,
FRONTEND_ROUTE_COMPAT_MAPS,
FRONTEND_SLOT_COMPAT_MAPS,
FRONTEND_SLOTS,
FRONTEND_WIDGET_COMPAT_MAPS,
MFE_APPS,
MFE_ATTRS_TYPE,
PLUGIN_SLOTS,
Expand Down Expand Up @@ -211,6 +218,128 @@ def get_frontend_slots() -> list[str]:
return FRONTEND_SLOTS.apply([])


# TODO(fpf-removal): get_frontend_compat_slots, get_frontend_compat_mfes,
# and their iter_* aliases exist to render the per-MFE env.config.<mfe>.jsx
# files, which serve frontend-base sites running the frontend-base
# compatibility shim (@openedx/frontend-base-compat). When FPF deprecation
# completes and no plugin contributes via FRONTEND_COMPAT_SLOTS or
# FRONTEND_COMPAT_PLUGINS, these helpers and the compat template can be
# removed.
@tutor_hooks.lru_cache
def get_frontend_compat_slots(mfe_name: str) -> list[tuple[str, str]]:
"""
Return the (slot_name, plugin_config) pairs to render through the compat
shim for the given MFE. The wildcard "all" is itself a valid mfe_name
here: it returns just the wildcard entries, which land in
env.config.all.jsx and get mounted without an mfeId so they fire
globally.

Sourced from two filters:

- FRONTEND_COMPAT_PLUGINS: bulk opt-ins by plugin name. Every PLUGIN_SLOTS
contribution registered in that plugin's hook context is folded in.
- FRONTEND_COMPAT_SLOTS: per-slot opt-ins. The plugin author registers
each (mfe_name, slot_name, plugin_config) triple they want rendered
through the shim.
"""
slots: list[tuple[str, str]] = []

for plugin_name in FRONTEND_COMPAT_PLUGINS.iterate():
context = tutor_hooks.Contexts.app(plugin_name).name
for plugin_mfe, slot_name, plugin_config in PLUGIN_SLOTS.iterate_from_context(
context
):
if plugin_mfe == mfe_name:
slots.append((slot_name, plugin_config))

for compat_mfe, slot_name, plugin_config in FRONTEND_COMPAT_SLOTS.iterate():
if compat_mfe == mfe_name:
slots.append((slot_name, plugin_config))

return slots


@tutor_hooks.lru_cache
def get_frontend_compat_mfes() -> list[str]:
"""
Return the sorted list of MFEs that should get a compat file: "all" stays
as a distinct entry here; the site mounts it without an mfeId so its
contributions fire globally.
"""
mfes: set[str] = set()

for plugin_name in FRONTEND_COMPAT_PLUGINS.iterate():
context = tutor_hooks.Contexts.app(plugin_name).name
for plugin_mfe, _slot_name, _plugin_config in PLUGIN_SLOTS.iterate_from_context(
context
):
mfes.add(plugin_mfe)

for compat_mfe, _slot_name, _plugin_config in FRONTEND_COMPAT_SLOTS.iterate():
mfes.add(compat_mfe)

return sorted(mfes)


def camelize_mfe_name(mfe_name: str) -> str:
"""
Convert a kebab/snake-case MFE name into a lowerCamelCase identifier
suitable as a prefix on JS export names (e.g. `<camel>EnvConfig`).
Examples: 'learner-dashboard' -> 'learnerDashboard',
'ora_grading' -> 'oraGrading', 'lms' -> 'lms'.
"""
parts = [p for p in re.split(r"[-_]+", mfe_name) if p]
if not parts:
return mfe_name
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])


@tutor_hooks.lru_cache
def get_frontend_slot_compat_map() -> dict[str, dict[str, t.Any]]:
return _merge_frontend_compat_maps(FRONTEND_SLOT_COMPAT_MAPS.iterate(), kind="slot")


@tutor_hooks.lru_cache
def get_frontend_widget_compat_map() -> dict[str, dict[str, t.Any]]:
return _merge_frontend_compat_maps(
FRONTEND_WIDGET_COMPAT_MAPS.iterate(), kind="widget"
)


@tutor_hooks.lru_cache
def get_frontend_route_compat_map() -> dict[str, list[str]]:
return _merge_frontend_compat_maps(
FRONTEND_ROUTE_COMPAT_MAPS.iterate(), kind="route"
)


def _merge_frontend_compat_maps(
maps: t.Iterable[tuple[str, t.Any]],
kind: str,
) -> dict[str, t.Any]:
"""
Identical contributions dedup silently. Divergent ones warn and
last-wins so a transient conflict during a plugin upgrade still
produces a working bundle.
"""
merged: dict[str, t.Any] = {}
for legacy_id, mapping in maps:
existing = merged.get(legacy_id)
if existing is None:
merged[legacy_id] = mapping
continue
if existing == mapping:
continue
fmt.echo_alert(
f"Conflicting compat {kind}-map extensions for {legacy_id!r}: "
f"{existing!r} overridden by {mapping!r}. "
"Last contribution wins; consider removing one of the plugins "
"or aligning their compat-map contributions."
)
merged[legacy_id] = mapping
return merged


# TODO(legacy-mfe-removal)
def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]:
"""
Expand Down Expand Up @@ -244,6 +373,22 @@ def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]:
yield from get_plugin_slots(mfe_name)


def iter_frontend_compat_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]:
"""
Yield:

(slot_name, plugin_config)
"""
yield from get_frontend_compat_slots(mfe_name)


def iter_frontend_compat_mfes() -> t.Iterable[str]:
"""
Yield each MFE name that has at least one compat-shim contribution.
"""
yield from get_frontend_compat_mfes()


def iter_external_scripts(mfe_name: str) -> t.Iterable[str]:
"""
Yield:
Expand Down Expand Up @@ -293,7 +438,15 @@ def get_frontend_app(app_name: str) -> t.Union[FRONTEND_APP_ATTRS_TYPE, t.Any]:
("get_frontend_app", get_frontend_app),
("get_frontend_apps", get_frontend_apps),
("iter_plugin_slots", iter_plugin_slots),
("iter_frontend_compat_slots", iter_frontend_compat_slots),
("iter_frontend_compat_mfes", iter_frontend_compat_mfes),
("iter_external_scripts", iter_external_scripts),
("get_frontend_compat_slots", get_frontend_compat_slots),
("get_frontend_compat_mfes", get_frontend_compat_mfes),
("get_frontend_slot_compat_map", get_frontend_slot_compat_map),
("get_frontend_widget_compat_map", get_frontend_widget_compat_map),
("get_frontend_route_compat_map", get_frontend_route_compat_map),
("camelize_mfe_name", camelize_mfe_name),
("iter_frontend_slots", iter_frontend_slots),
("is_mfe_enabled", is_mfe_enabled),
("is_frontend_app_enabled", is_frontend_app_enabled),
Expand Down Expand Up @@ -554,3 +707,30 @@ def _check_mfe_host(config: Config) -> None:
"This configuration is not typically recommended and may lead "
"to unexpected behavior."
)


# TODO(fpf-removal): goes away with env.config.compat.jsx itself.
@tutor_hooks.Actions.ENV_SAVED.add()
def _render_per_mfe_compat_files(root: str, config: Config) -> None:
"""
Render one env.config.<mfe>.jsx into the site source tree per MFE that has
at least one opted-in compat slot, and reap any stale files left behind
when a plugin gets disabled. Tutor's normal template-tree rendering can't
fan one source file out into N rendered files, so we render it once per
MFE with current_mfe set as a Jinja global.
"""
mfes = get_frontend_compat_mfes()
out_dir = os.path.join(root, "plugins", "mfe", "build", "mfe", "site", "src")
if mfes:
renderer = tutor_env.Renderer(config)
for mfe_name in mfes:
renderer.environment.globals["current_mfe"] = mfe_name
rendered = renderer.render_template("mfe/partials/env.config.compat.jsx")
tutor_env.write_to(
rendered,
os.path.join(out_dir, f"env.config.{mfe_name}.jsx"),
)
valid = {f"env.config.{mfe_name}.jsx" for mfe_name in mfes}
for path in glob(os.path.join(out_dir, "env.config.*.jsx")):
if os.path.basename(path) not in valid:
os.remove(path)
11 changes: 11 additions & 0 deletions tutormfe/templates/mfe/build/mfe/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,19 @@
{%- for app_name, app in iter_frontend_apps() if app.get("npm_package") %}
"{{ app['npm_package'] }}": "{{ app['npm_version'] }}",
{%- endfor %}
{%- if get_frontend_compat_mfes() %}
"@openedx/frontend-base-compat": "^1.0.0-alpha || 0.0.0-dev",
"@edx/frontend-platform": "npm:@openedx/frontend-base-compat@^1.0.0-alpha || 0.0.0-dev",
"@openedx/frontend-plugin-framework": "npm:@openedx/frontend-base-compat@^1.0.0-alpha || 0.0.0-dev",
{%- endif %}
"@openedx/frontend-base": "^1.0.0-alpha || 0.0.0-dev"
},
{%- if get_frontend_compat_mfes() %}
"overrides": {
"@edx/frontend-platform": "npm:@openedx/frontend-base-compat@^1.0.0-alpha || 0.0.0-dev",
"@openedx/frontend-plugin-framework": "npm:@openedx/frontend-base-compat@^1.0.0-alpha || 0.0.0-dev"
},
{%- endif %}
"devDependencies": {
"@edx/browserslist-config": "latest",
"turbo": "^2.8.16"
Expand Down
Loading
Loading