Skip to content
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

[DO NOT MERGE]: Added script loader #708

Closed
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
66 changes: 66 additions & 0 deletions docs/how_tos/embedding-custom-scripts.rst
Original file line number Diff line number Diff line change
@@ -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 `<head>` section or various positions within the `<body>`.

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 `<head>` section.
- `body.top`: Inserts the script at the beginning of the `<body>` section.
- `body.bottom`: Inserts the script at the end of the `<body>` 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": {
"<MFE>": {
"EXTERNAL_SCRIPTS": [
{
"head": "",
"body": {
"top": "",
"bottom": "<script src=\"https://example.com/widget/loader.js\"></script>"
}
}
]
}
}
}

Example 1: Using `MFE_CONFIG`
---------------------------------------
```json
{
"MFE_CONFIG": {
"EXTERNAL_SCRIPTS": [
{
"head": "<script>console.log('Inline script in head');</script>",
"body": {
"top": "<script>console.log('Inline script at body top');</script>",
"bottom": "<script src=\"https://example.com/widget/loader.js\"></script>"
}
}
]
}
}
2 changes: 1 addition & 1 deletion docs/template/edx/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions src/scripts/ScriptInserter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Class representing a Script Inserter.
*/
class ScriptInserter {
/**
* Create a Script Inserter.
* @param {Array<Object>} 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;
94 changes: 94 additions & 0 deletions src/scripts/ScriptInserter.test.js
Original file line number Diff line number Diff line change
@@ -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: '<script>console.log("First head script");</script>',
body: {
top: '<script>console.log("First body top script");</script>',
bottom: '<script>console.log("First body bottom script");</script>',
},
},
{
head: '<script src="https://example.com/second-script.js"></script>',
},
{
body: {
top: '<script>console.log("Third body top script");</script>',
},
},
],
},
};
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);
});
});
});
2 changes: 1 addition & 1 deletion src/scripts/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as GoogleAnalyticsLoader } from './GoogleAnalyticsLoader';
export { default as ScriptInserter } from './ScriptInserter';
Loading