diff --git a/docs/how_tos/embedding-custom-scripts.rst b/docs/how_tos/embedding-custom-scripts.rst new file mode 100644 index 000000000..26ad26886 --- /dev/null +++ b/docs/how_tos/embedding-custom-scripts.rst @@ -0,0 +1,66 @@ +########################## +Embedding Custom Scripts +########################## + +.. contents:: Table of Contents + +Introduction +************ + +In modern web applications, there is a need to embed external scripts to expand the functionality of the application or integrate third-party services (analytics tools, widgets, etc.). +This tutorial explains how to add custom scripts through Django site configurations. + +Configuration Overview +======================= + +Configuration for embedding custom scripts can be done through the global `MFE_CONFIG` and the `MFE_CONFIG_OVERRIDES`. These configurations allow specifying scripts to be inserted into different parts of the HTML document, such as the `` section or various positions within the ``. + +Configuring External Scripts +============================= + +External scripts can be specified in the `MFE_CONFIG` or `MFE_CONFIG_OVERRIDES` JSON objects. Each script can be inserted into one of the following locations: +- `head`: Inserts the script into the `` section. +- `body.top`: Inserts the script at the beginning of the `` section. +- `body.bottom`: Inserts the script at the end of the `` section. + +Scripts can be provided either as a URL (`src`) or as inline script content. + +Example Configuration +===================== + +Example 1: Using `MFE_CONFIG_OVERRIDES` +--------------------------------------- + +```json +{ + "MFE_CONFIG_OVERRIDES": { + "": { + "EXTERNAL_SCRIPTS": [ + { + "head": "", + "body": { + "top": "", + "bottom": "" + } + } + ] + } + } +} + +Example 1: Using `MFE_CONFIG` +--------------------------------------- +```json +{ + "MFE_CONFIG": { + "EXTERNAL_SCRIPTS": [ + { + "head": "", + "body": { + "top": "", + "bottom": "" + } + } + ] + } +} diff --git a/docs/template/edx/publish.js b/docs/template/edx/publish.js index 4c390f56c..1444de9d5 100644 --- a/docs/template/edx/publish.js +++ b/docs/template/edx/publish.js @@ -653,7 +653,7 @@ exports.publish = (memberData, opts, tutorials) => { const myNamespaces = members.namespaces.filter(obj => obj.longname === longname); const trimModuleName = (moduleName) => { - if (moduleName.includes('module:')) { + if (moduleName?.includes('module:')) { return moduleName.split(':')[1]; } return moduleName; diff --git a/src/initialize.js b/src/initialize.js index 2c7223595..438836585 100644 --- a/src/initialize.js +++ b/src/initialize.js @@ -68,7 +68,7 @@ import { import { configure as configureAnalytics, SegmentAnalyticsService, identifyAnonymousUser, identifyAuthenticatedUser, } from './analytics'; -import { GoogleAnalyticsLoader } from './scripts'; +import { GoogleAnalyticsLoader, ScriptInserter } from './scripts'; import { getAuthenticatedHttpClient, configure as configureAuth, @@ -290,7 +290,7 @@ export async function initialize({ analyticsService = SegmentAnalyticsService, authService = AxiosJwtAuthService, authMiddleware = [], - externalScripts = [GoogleAnalyticsLoader], + externalScripts = [GoogleAnalyticsLoader, ScriptInserter], requireAuthenticatedUser: requireUser = false, hydrateAuthenticatedUser: hydrateUser = false, messages, diff --git a/src/scripts/ScriptInserter.js b/src/scripts/ScriptInserter.js new file mode 100644 index 000000000..6c340842d --- /dev/null +++ b/src/scripts/ScriptInserter.js @@ -0,0 +1,97 @@ +/** + * Class representing a Script Inserter. + */ +class ScriptInserter { + /** + * Create a Script Inserter. + * @param {Array} scripts - An array of script objects to insert. + * @param {string} [scripts[].head] - The script to insert into the head section. + * @param {string} [scripts[].body.top] - The script to insert at the top of the body section. + * @param {string} [scripts[].body.bottom] - The script to insert at the bottom of the body section. + */ + constructor({ config }) { + this.scripts = config.EXTERNAL_SCRIPTS || []; + } + + /** + * Inserts the scripts into their respective locations (head, body start, body end). + */ + loadScript() { + if (!this.scripts.length) { + return; + } + + this.scripts.forEach((script) => { + if (script.head) { + this.insertToHead(script.head); + } + if (script.body?.top) { + this.insertToBodyTop(script.body.top); + } + if (script.body?.bottom) { + this.insertToBodyBottom(script.body.bottom); + } + }); + } + + /** + * Inserts content into the head section. + * @param {string} content - The content to insert into the head section. + */ + insertToHead(content) { + this.createAndAppendScript(content, document.head); + } + + /** + * Inserts content at the start of the body section. + * @param {string} content - The content to insert at the top of the body section. + */ + insertToBodyTop(content) { + this.createAndAppendScript(content, document.body, true); + } + + /** + * Inserts content at the end of the body section. + * @param {string} content - The content to insert at the bottom of the body section. + */ + insertToBodyBottom(content) { + this.createAndAppendScript(content, document.body); + } + + /** + * Creates a script element and appends it to the specified location. + * @param {string} content - The content of the script. + * @param {Element} parent - The parent element to insert the script into (head or body). + * @param {boolean} atStart - Whether to insert the script at the start of the parent element. + */ + createAndAppendScript(content, parent, atStart = false) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; + const scriptElement = tempDiv.querySelector('script'); + + if (scriptElement && scriptElement.src) { + // If the script has a src attribute, create a new script element with the same src + const newScriptElement = document.createElement('script'); + newScriptElement.src = scriptElement.src; + newScriptElement.async = true; + + if (atStart && parent.firstChild) { + parent.insertBefore(newScriptElement, parent.firstChild); + } else { + parent.appendChild(newScriptElement); + } + } else { + // If the script does not have a src attribute, insert its inner content as inline script + const newScriptElement = document.createElement('script'); + newScriptElement.text = scriptElement ? scriptElement.innerHTML : content; + + if (atStart && parent.firstChild) { + parent.insertBefore(newScriptElement, parent.firstChild); + } else { + parent.appendChild(newScriptElement); + } + } + } +} + +export default ScriptInserter; diff --git a/src/scripts/ScriptInserter.test.js b/src/scripts/ScriptInserter.test.js new file mode 100644 index 000000000..20a8c5836 --- /dev/null +++ b/src/scripts/ScriptInserter.test.js @@ -0,0 +1,94 @@ +import ScriptInserter from './ScriptInserter'; + +describe('ScriptInserter', () => { + let data; + + beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + function loadScripts(scriptData) { + const scriptInserter = new ScriptInserter(scriptData); + scriptInserter.loadScript(); + } + + describe('with multiple scripts', () => { + beforeEach(() => { + data = { + config: { + EXTERNAL_SCRIPTS: [ + { + head: '', + body: { + top: '', + bottom: '', + }, + }, + { + head: '', + }, + { + body: { + top: '', + }, + }, + ], + }, + }; + loadScripts(data); + }); + + it('should insert all head scripts', () => { + const headScripts = document.head.querySelectorAll('script'); + expect(headScripts.length).toBe(2); + + const inlineHeadScript = Array.from(headScripts) + .find(script => script.src === '' && script.innerHTML.includes('console.log("First head script")')); + const srcHeadScript = document.head + .querySelector('script[src="https://example.com/second-script.js"]'); + + expect(inlineHeadScript).not.toBeNull(); + expect(srcHeadScript).not.toBeNull(); + expect(srcHeadScript.async).toBe(true); + }); + + it('should insert all body top scripts in correct order', () => { + const bodyTopScripts = document.body.querySelectorAll('script'); + expect(bodyTopScripts.length).toBe(3); // Top scripts + Bottom script + + const firstTopScript = Array.from(bodyTopScripts) + .find(script => script.innerHTML.includes('console.log("First body top script")')); + const thirdTopScript = Array.from(bodyTopScripts) + .find(script => script.innerHTML.includes('console.log("Third body top script")')); + + expect(firstTopScript).not.toBeNull(); + expect(thirdTopScript).not.toBeNull(); + }); + + it('should insert all body bottom scripts', () => { + const bodyBottomScripts = Array.from(document.body.querySelectorAll('script')) + .filter(script => script.innerHTML.includes('First body bottom script')); + expect(bodyBottomScripts.length).toBe(1); + + const firstBottomScript = bodyBottomScripts[0]; + expect(firstBottomScript.innerHTML).toBe('console.log("First body bottom script");'); + }); + }); + + describe('with no external scripts', () => { + beforeEach(() => { + data = { + config: { + EXTERNAL_SCRIPTS: [], + }, + }; + loadScripts(data); + }); + + it('should not insert any scripts', () => { + expect(document.head.querySelectorAll('script').length).toBe(0); + expect(document.body.querySelectorAll('script').length).toBe(0); + }); + }); +}); diff --git a/src/scripts/index.js b/src/scripts/index.js index 3c627f0cc..bd36a2e3f 100644 --- a/src/scripts/index.js +++ b/src/scripts/index.js @@ -1,2 +1,2 @@ -/* eslint-disable import/prefer-default-export */ export { default as GoogleAnalyticsLoader } from './GoogleAnalyticsLoader'; +export { default as ScriptInserter } from './ScriptInserter';