Repository navigation
feat: add an opt-in MFE theme to the Text (HTML) XBlock #308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
25702c6
a099a7f
394b71d
949f631
237681c
8d9cbc6
2ce25ba
cf783e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** | ||
| * Renders a Text (HTML) XBlock inside a shadow root so that the block is styled | ||
| * by the MFE theme without leaking styles in either direction. | ||
| * | ||
| * The block's HTML is rendered server-side, so there is nothing to "render" | ||
| * here: the job is to move the existing children into a shadow root and attach | ||
| * the theme stylesheets to it. | ||
| */ | ||
| (function () { | ||
| 'use strict'; | ||
|
|
||
| var CDN_CORE = 'https://cdn.jsdelivr.net/npm/@openedx/paragon@23/dist/core.min.css'; | ||
|
|
||
| /** | ||
| * Fetch the Paragon theme stylesheet URLs from the MFE config API. | ||
| * | ||
| * Returns `core` and `theme` arrays built from PARAGON_THEME_URLS. Each entry | ||
| * may be layered (Paragon's stylesheet plus a brand override), so both URLs are | ||
| * kept; only `core` falls back to the CDN when the deployment publishes none. | ||
| * Studio's editor reads the same key and builds its preview from it, so whatever | ||
| * is attached here is what is previewed there. | ||
| * | ||
| * @param {string} mfeConfigApiUrl - URL of the MFE config API. | ||
| * @returns {Promise<{core: string[], theme: string[]}>} | ||
| */ | ||
| async function getThemes(mfeConfigApiUrl) { | ||
| let themeUrls; | ||
| try { | ||
| var response = await fetch(mfeConfigApiUrl); | ||
| var mfeConfig = await response.json(); | ||
| themeUrls = mfeConfig.PARAGON_THEME_URLS || {}; | ||
| } catch (error) { | ||
| // Not fatal: the block still renders, just with the CDN defaults. | ||
| console.error('Text XBlock: failed to fetch theme URLs:', error); | ||
| themeUrls = {}; | ||
| } | ||
| var variant = themeUrls.variants && themeUrls.variants[activeVariant(themeUrls)]; | ||
| var coreUrls = layerUrls(themeUrls.core); | ||
| return { | ||
| core: coreUrls.length ? coreUrls : [CDN_CORE], | ||
| theme: layerUrls(variant), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Work out which variant is active. | ||
| * | ||
| * Two shapes are published in practice: frontend-base's `Theme` | ||
| * (https://github.com/openedx/frontend-base/blob/main/types.ts) carries an optional | ||
| * `defaults` map naming the active light and dark variants, while tutor-indigo | ||
| * (https://github.com/overhangio/tutor-indigo) ships only a `variants` map with | ||
| * nothing pointing at one. So read `defaults` when it is there, and otherwise | ||
|
Comment on lines
+50
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rpenido Note that the Theme interface type in frontend-base is very lenient: the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right. For now, nit update to the docs here: 949f631 |
||
| * take the first variant present rather than dropping a configured theme. | ||
| */ | ||
| function activeVariant(themeUrls) { | ||
| if (themeUrls.defaults && themeUrls.defaults.light) { | ||
| return themeUrls.defaults.light; | ||
| } | ||
| var variants = themeUrls.variants || {}; | ||
| if (variants.light) { | ||
| return 'light'; | ||
| } | ||
| var names = Object.keys(variants); | ||
| return names.length ? names[0] : null; | ||
| } | ||
|
|
||
| /** | ||
| * Return the stylesheet URLs for a `core` or `variants` entry, in the order | ||
| * the platform applies them. | ||
| * | ||
| * Entries appear either nested (`{urls: {default, brandOverride}}`, as | ||
| * frontend-platform and tutor-indigo publish) or flat (`{url}`). The nested | ||
| * form is layered: `default` is the Paragon stylesheet and `brandOverride` is | ||
| * the brand package, which is built to be imported *on top of* it rather than | ||
| * to replace it. Returning both matches frontend-platform's loader; a | ||
| * deployment that points them at the same file is de-duplicated by the caller. | ||
| */ | ||
| function layerUrls(entry) { | ||
| if (!entry) { | ||
| return []; | ||
| } | ||
| if (entry.urls) { | ||
| return [entry.urls.default, entry.urls.brandOverride].filter(Boolean); | ||
| } | ||
| return entry.url ? [entry.url] : []; | ||
| } | ||
|
|
||
| /** | ||
| * Attach a stylesheet inside the shadow root only. | ||
| * | ||
| * Paragon declares its custom properties on `:root`, which matches nothing | ||
| * inside a shadow tree, but they still reach the content by inheritance through | ||
| * the host element. Attaching to the document as well would restyle every | ||
| * other block sharing the page, since Paragon core carries top-level rules for | ||
| * bare element selectors. | ||
| */ | ||
| function addStylesheet(shadowRoot, url) { | ||
| var link = document.createElement('link'); | ||
| link.rel = 'stylesheet'; | ||
| link.href = url; | ||
| shadowRoot.appendChild(link); | ||
| } | ||
|
|
||
| /** | ||
| * Move the block's server-rendered children into a shadow root. | ||
| */ | ||
| function sandbox(element) { | ||
| if (element.shadowRoot) { | ||
| return element.shadowRoot.querySelector('.xblock-root'); | ||
| } | ||
|
|
||
| var shadowRoot = element.attachShadow({ mode: 'open' }); | ||
| var root = document.createElement('div'); | ||
| root.classList.add('xblock-root'); | ||
|
|
||
| // Adopt the rendered content rather than re-rendering it, so anything the | ||
| // server produced (images, anchors, embedded markup) is preserved as-is. | ||
| while (element.firstChild) { | ||
| root.appendChild(element.firstChild); | ||
| } | ||
|
|
||
| shadowRoot.appendChild(root); | ||
| return root; | ||
| } | ||
|
|
||
| /** | ||
| * XBlock view entry point. Invoked by the XBlock JS runtime as | ||
| * `HtmlBlock(runtime, element, initArgs)`. | ||
| * | ||
| * @param {Object} runtime - XBlock runtime (unused). | ||
| * @param {Element} element - The block's root element. | ||
| * @param {Object} initArgs - Data supplied by `Fragment.initialize_js`. | ||
| */ | ||
| function HtmlBlock(runtime, element, initArgs) { | ||
| if (!initArgs || !initArgs.use_mfe_theming) { | ||
| return; | ||
| } | ||
|
|
||
| var root = sandbox(element); | ||
|
|
||
| getThemes(initArgs.mfe_config_api).then(function (themes) { | ||
| var seen = {}; | ||
| [...themes.core, ...themes.theme].forEach(function (url) { | ||
| if (!seen[url]) { | ||
| seen[url] = true; | ||
| addStylesheet(root.getRootNode(), url); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| window.HtmlBlock = HtmlBlock; | ||
| }()); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rpenido I feel like for best backwards compatibility, it would be good to initialise the non-include-theme variant exactly as before, including the barebones HtmlBlock js implementation. Although I'm not sure about namespacing here - would the HtmlBlock js function conflict with that of other Text blocks on the same page?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure if I agree with you on this one. Since we added more functionality to the init function (using
html_block.jsinstead of just using an empty function like before), I think we should load it for every case and make it handle the parameters there, abstracting its logic fromhtml.py.In the future if someone needs other JS features for our HTML block, he doesn't need to add a new condition for loading the
html_block.jsor, in the worst case, be tempted to add a newhtml_block_for_my_feature.jsfile.Does that make sense?
And the new
HTMLBlockdeclared function early returns if we don't have initArgs of theincluded_theme. I don't think we are at risk to add a regression here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rpenido yep that makes sense, thanks for checking :) I think I'm just aware that this html block is also used for the "raw html" block, which is used for arbitrary interactive (js) features - eg. the demo course feedback buttons: