Skip to content
Open
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 app/public/custom-scripts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
11 changes: 11 additions & 0 deletions src/mixins/communicationBridgeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import { runCustomScriptsForNewContent } from 'docc-render/utils/custom-scripts';

export default {
inject: {
performanceMetricsEnabled: {
Expand Down Expand Up @@ -40,6 +42,15 @@ export default {
time,
},
});

// Run custom scripts now that the page's content is in the DOM, so that they can customize
// it. When a page relies on data being provided via $bridge, this is first called before
// there's any content, so skip running custom scripts until there is.
if (this.topicData) {
runCustomScriptsForNewContent().catch((error) => {
console.error('Failed to run custom scripts:', error);
});
}
},
/**
* @param {Object} data
Expand Down
254 changes: 254 additions & 0 deletions src/utils/custom-scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* This source file is part of the Swift.org open source project
*
* Copyright (c) 2021 Apple Inc. and the Swift project authors
* Licensed under Apache License v2.0 with Runtime Library Exception
*
* See https://swift.org/LICENSE.txt for license information
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import {
copyPresentProperties,
copyPropertyIfPresent,
has,
mustNotHave,
} from 'docc-render/utils/object-properties';
import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';

/** Enum for the allowed values of the `run` property in a custom script. */
const Run = {
onLoad: 'on-load',
onLoadAndNavigate: 'on-load-and-navigate',
onNavigate: 'on-navigate',
};

/**
* Returns whether the custom script should be run when the reader navigates to a subpage.
* @param {object} customScript
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
* "on-load" or "on-load-and-navigate". Also returns true if the `run` property is absent.
*/
function shouldRunOnPageLoad(customScript) {
return !has(customScript, 'run')
|| customScript.run === Run.onLoad || customScript.run === Run.onLoadAndNavigate;
}

/**
* Returns whether the custom script should be run when the reader navigates to a topic.
* @param {object} customScript
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
* "on-navigate" or "on-load-and-navigate".
*/
function shouldRunOnNavigate(customScript) {
return has(customScript, 'run')
&& (customScript.run === Run.onNavigate || customScript.run === Run.onLoadAndNavigate);
}

/**
* Gets the URL for a local custom script given its name.
* @param {string} customScriptName The name of the custom script as spelled in
* custom-scripts.json. While the actual filename (in the custom-scripts directory) is always
* expected to end in ".js", the name in custom-scripts.json may or may not include the ".js"
* extension.
* @returns {string} The absolute URL where the script is, accounting for baseURL.
* @example
* // if baseURL is '/foo'
* urlGivenScriptName('hello-world') // http://localhost:8080/foo/hello-world.js
* urlGivenScriptName('hello-world.js') // http://localhost:8080/foo/hello-world.js
*/
function urlGivenScriptName(customScriptName) {
let scriptNameWithExtension = customScriptName;

// If the provided name does not already include the ".js" extension, add it.
if (customScriptName.slice(-3) !== '.js') {
scriptNameWithExtension = `${customScriptName}.js`;
}

return resolveAbsoluteUrl(['', 'custom-scripts', scriptNameWithExtension]);
}

/**
* Describes a custom script for error messages.
* @param {object} customScript
* @returns {string} The script's URL or name, or its JSON representation if it has neither.
*/
function describe(customScript) {
return customScript.url || customScript.name || JSON.stringify(customScript);
}

/**
* Creates an HTMLScriptElement for the custom script.
* @param {object} customScript The custom script.
* @returns {HTMLScriptElement} A script element that runs the custom script once it's added to the
* document.
*/
function createScriptElement(customScript) {
const scriptElement = document.createElement('script');

copyPropertyIfPresent('type', customScript, scriptElement);

if (has(customScript, 'url')) {
mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');

scriptElement.src = customScript.url;

// Dynamically-created script elements are `async` by default, meaning that they run in
// whatever order they finish loading. But custom scripts should run in the order in which
// they're listed in custom-scripts.json, so that, for example, a library's configuration
// script can run before the library itself.
//
// Therefore, remove the script element's `async` unless async is explicitly enabled.
scriptElement.async = customScript.async || false;

copyPresentProperties(['integrity'], customScript, scriptElement);

// If `integrity` is set on an external script, then CORS must be enabled as well.
if (has(customScript, 'integrity')) {
scriptElement.crossOrigin = 'anonymous';
}
} else if (has(customScript, 'name')) {
scriptElement.src = urlGivenScriptName(customScript.name);
scriptElement.async = customScript.async || false;

copyPresentProperties(['async', 'integrity'], customScript, scriptElement);
} else {
throw new Error('Custom script does not have `url` or `name` properties.');
}

return scriptElement;
}

/**
* Add an HTMLScriptElement containing the custom script to the document's head, which runs the
* script on page load.
* @param {object} customScript The custom script, assuming it should be run on page load.
*/
function addScriptElement(customScript) {
document.head.appendChild(createScriptElement(customScript));
}

/**
* Runs the custom script again by adding a new HTMLScriptElement for it to the document's head,
* and removes the element once the script has run or failed to load. Useful for running a custom
* script anytime after page load, namely when the reader navigates to a subpage.
*
* Unlike evaluating the script's source code with `eval` or `new Function`, this works on websites
* whose Content Security Policy disallows `unsafe-eval`.
* @param {object} customScript The custom script, assuming it should be run on navigate.
* @returns {Promise<void>} A promise that resolves once the script has run, or rejects if the
* custom script is invalid or fails to load.
*/
function rerunScriptElement(customScript) {
return new Promise((resolve, reject) => {
// If the custom script is invalid, this throws, which rejects the promise.
const scriptElement = createScriptElement(customScript);

scriptElement.addEventListener('load', () => {
scriptElement.remove();
resolve();
}, { once: true });
scriptElement.addEventListener('error', () => {
scriptElement.remove();
reject(new Error(`Failed to load ${scriptElement.src}.`));
}, { once: true });

document.head.appendChild(scriptElement);
});
}

/**
* Fetches the custom scripts declared in custom-scripts.json from the server.
* @returns {Promise<object[]>} The custom scripts, or an empty array if the file is absent.
*/
async function fetchCustomScriptsFromServer() {
const customScriptsFileName = 'custom-scripts.json';
const url = resolveAbsoluteUrl(`/${customScriptsFileName}`);

const response = await fetch(url);
if (!response.ok) {
// If the file is absent, fail silently.
return [];
}

const customScripts = await response.json();
if (!Array.isArray(customScripts)) {
throw new Error(`Content of ${customScriptsFileName} should be an array.`);
}

return customScripts;
}

/** The custom scripts, fetched once and then reused for every routing event. */
let customScriptsPromise;

/**
* Fetches the custom scripts declared in custom-scripts.json, only requesting the file once.
* @returns {Promise<object[]>} The custom scripts, or an empty array if the file is absent.
*/
function fetchCustomScripts() {
if (!customScriptsPromise) {
customScriptsPromise = fetchCustomScriptsFromServer().catch((error) => {
// Don't cache the failure, so that the next routing event tries again.
customScriptsPromise = undefined;
throw error;
});
}
return customScriptsPromise;
}

/**
* Runs all "on-load" and "on-load-and-navigate" scripts.
* @returns {Promise<void>}
*/
export async function runCustomPageLoadScripts() {
const customScripts = await fetchCustomScripts();

// An invalid script doesn't prevent the other scripts from running.
customScripts.filter(shouldRunOnPageLoad).forEach((customScript) => {
try {
addScriptElement(customScript);
} catch (error) {
console.error(`Failed to add custom script (${describe(customScript)}):`, error);
}
});
}

/**
* Runs all "on-navigate" and "on-load-and-navigate" scripts.
* @returns {Promise<void>} A promise that resolves once all the scripts have run or failed.
*/
export async function runCustomNavigateScripts() {
const customScripts = (await fetchCustomScripts()).filter(shouldRunOnNavigate);

// The script elements are all added at once so that the browser loads the scripts in parallel,
// but since they aren't `async` (unless explicitly enabled), the scripts still run in the order
// in which they're listed. A script that's invalid or fails to load doesn't prevent the other
// scripts from running.
const results = await Promise.allSettled(customScripts.map(rerunScriptElement));
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to run custom script (${describe(customScripts[index])}):`,
result.reason,
);
}
});
}

/** Whether the page load scripts have run, meaning that any new content is from a navigation. */
let hasRunPageLoadScripts = false;

/**
* Runs the custom scripts for a page's new content, once it has been rendered: the page load
* scripts for the first content, and the navigation scripts for any content after that.
* @returns {Promise<void>}
*/
export async function runCustomScriptsForNewContent() {
if (hasRunPageLoadScripts) {
await runCustomNavigateScripts();
} else {
hasRunPageLoadScripts = true;
await runCustomPageLoadScripts();
}
}
48 changes: 48 additions & 0 deletions src/utils/object-properties.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* This source file is part of the Swift.org open source project
*
* Copyright (c) 2021 Apple Inc. and the Swift project authors
* Licensed under Apache License v2.0 with Runtime Library Exception
*
* See https://swift.org/LICENSE.txt for license information
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

/** Convenient shorthand for `Object.hasOwn`. */
export const has = Object.hasOwn;
/**
* Copies source.property, if it exists, to destination.property.
* @param {string} property
* @param {object} source
* @param {object} destination
*/
export function copyPropertyIfPresent(property, source, destination) {
if (has(source, property)) {
// eslint-disable-next-line no-param-reassign
destination[property] = source[property];
}
}

/**
* Copies all specified properties present in the source to the destination.
* @param {string[]} properties
* @param {object} source
* @param {object} destination
*/
export function copyPresentProperties(properties, source, destination) {
properties.forEach((property) => {
copyPropertyIfPresent(property, source, destination);
});
}

/**
* Throws an error if `object` has the property `property`.
* @param {object} object
* @param {string} property
* @param {string} errorMessage
*/
export function mustNotHave(object, property, errorMessage) {
if (has(object, property)) {
throw new Error(errorMessage);
}
}
2 changes: 1 addition & 1 deletion src/utils/theme-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const themeSettingsState = {
export const baseUrl = new URL(document.baseURI).pathname;

/**
* Method to fetch the theme settings and store in local module state.
* Fetches the theme settings and store in local module state.
* Method is called before Vue boots in `main.js`.
* @return {Promise<{}>}
*/
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/imagePreloading/imagePreloading.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import hide from 'docc-render/directives/hide';
import Topic from 'docc-render/views/Topic.vue';
import createRouterInstance from 'docc-render/setup-utils/SwiftDocCRenderRouter';

// Custom scripts are tested separately; don't fetch custom-scripts.json when content is rendered.
jest.mock('docc-render/utils/custom-scripts', () => ({
runCustomScriptsForNewContent: jest.fn().mockResolvedValue(),
}));

const router = createRouterInstance();

jest.mock('docc-render/utils/theme-settings');
Expand Down
Loading