diff --git a/src/App.vue b/src/App.vue index 742765f74..45e637bdf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -46,6 +46,7 @@ import ColorScheme from 'docc-render/constants/ColorScheme'; import Footer from 'docc-render/components/Footer.vue'; import InitialLoadingPlaceholder from 'docc-render/components/InitialLoadingPlaceholder.vue'; import { baseNavStickyAnchorId } from 'docc-render/constants/nav'; +import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts'; import { fetchThemeSettings, themeSettingsState, getSetting } from 'docc-render/utils/theme-settings'; import { objectToCustomProperties } from 'docc-render/utils/themes'; import { AppTopID } from 'docc-render/constants/AppTopID'; @@ -70,6 +71,7 @@ export default { return { AppTopID, appState: AppStore.state, + initialRoutingEventHasOccurred: false, fromKeyboard: false, isTargetIDE: process.env.VUE_APP_TARGET === 'ide', themeSettings: themeSettingsState, @@ -107,6 +109,30 @@ export default { }, }, watch: { + async $route() { + // A routing event has just occurred, which is either the initial page load or a subsequent + // navigation. So load any custom scripts that should be run, based on their `run` property, + // after this routing event. + // + // This hook, and (as a result) any appropriate custom scripts for the current routing event, + // are called *after* the HTML for the current route has been dynamically added to the DOM. + // This means that custom scripts have access to the documentation HTML for the current + // topic (or tutorial, etc). + + if (this.initialRoutingEventHasOccurred) { + // The initial page load counts as a routing event, so we only want to run "on-navigate" + // scripts from the second routing event onward. + await runCustomNavigateScripts(); + } else { + // The "on-load" scripts are run here (on the routing hook), not on `created` or `mounted`, + // so that the scripts have access to the dynamically-added documentation HTML for the + // current topic. + await runCustomPageLoadScripts(); + + // The next time we enter the routing hook, run the navigation scripts. + this.initialRoutingEventHasOccurred = true; + } + }, CSSCustomProperties: { immediate: true, handler(CSSCustomProperties) { diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js new file mode 100644 index 000000000..bfc77d006 --- /dev/null +++ b/src/utils/custom-scripts.js @@ -0,0 +1,192 @@ +/** + * 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 fetchText from 'docc-render/utils/fetch-text'; +import { + copyPresentProperties, + copyPropertyIfPresent, + has, + mustNotHave, +} from 'docc-render/utils/object-properties'; +import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper'; + +/** + * 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 === 'on-load' || customScript.run === 'on-load-and-navigate'; +} + +/** + * 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 === 'on-navigate' || customScript.run === 'on-load-and-navigate'); +} + +/** + * 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 if '/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]); +} + +/** + * 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) { + const scriptElement = document.createElement('script'); + + copyPropertyIfPresent('type', customScript, scriptElement); + + if (has(customScript, 'url')) { + mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.'); + mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.'); + + scriptElement.src = customScript.url; + + copyPresentProperties(['async', 'defer', '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')) { + mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.'); + + scriptElement.src = urlGivenScriptName(customScript.name); + + copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement); + } else if (has(customScript, 'code')) { + mustNotHave(customScript, 'async', 'Inline script cannot be `async`.'); + mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.'); + mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.'); + + scriptElement.innerHTML = customScript.code; + } else { + throw new Error('Custom script does not have `url`, `name`, or `code` properties.'); + } + + document.head.appendChild(scriptElement); +} + +/** + * Run the custom script using `eval`. Useful for running a custom script anytime after page load, + * namely when the reader navigates to a subpage. + * @param {object} customScript The custom script, assuming it should be run on navigate. + */ +async function evalScript(customScript) { + let codeToEval; + + if (has(customScript, 'url')) { + mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.'); + mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.'); + + if (has(customScript, 'integrity')) { + // External script with integrity. Must also use CORS. + codeToEval = await fetchText(customScript.url, { + integrity: customScript.integrity, + crossOrigin: 'anonymous', + }); + } else { + // External script without integrity. + codeToEval = await fetchText(customScript.url); + } + } else if (has(customScript, 'name')) { + mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.'); + + const url = urlGivenScriptName(customScript.name); + + if (has(customScript, 'integrity')) { + // Local script with integrity. Do not use CORS. + codeToEval = await fetchText(url, { integrity: customScript.integrity }); + } else { + // Local script without integrity. + codeToEval = await fetchText(url); + } + } else if (has(customScript, 'code')) { + mustNotHave(customScript, 'async', 'Inline script cannot be `async`.'); + mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.'); + mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.'); + + codeToEval = customScript.code; + } else { + throw new Error('Custom script does not have `url`, `name`, or `code` properties.'); + } + + // eslint-disable-next-line no-eval + eval(codeToEval); +} + +/** + * Run all custom scripts that pass the `predicate` using the `executor`. + * @param {(customScript: object) => boolean} predicate + * @param {(customScript: object) => void} executor + * @returns {Promise} + */ +async function runCustomScripts(predicate, executor) { + 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.`); + } + + customScripts.filter(predicate).forEach(executor); +} + +/** + * Runs all "on-load" and "on-load-and-navigate" scripts. + * @returns {Promise} + */ +export async function runCustomPageLoadScripts() { + await runCustomScripts(shouldRunOnPageLoad, addScriptElement); +} + +/** + * Runs all "on-navigate" and "on-load-and-navigate" scripts. + * @returns {Promise} + */ +export async function runCustomNavigateScripts() { + await runCustomScripts(shouldRunOnNavigate, evalScript); +} diff --git a/src/utils/fetch-text.js b/src/utils/fetch-text.js new file mode 100644 index 000000000..5ad5da558 --- /dev/null +++ b/src/utils/fetch-text.js @@ -0,0 +1,23 @@ +/** + * 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 { resolveAbsoluteUrl } from 'docc-render/utils/url-helper'; + +/** + * Fetch the contents of a file as text. + * @param {string} filepath The file path. + * @param {RequestInit?} options Optional request settings. + * @returns {Promise} The text contents of the file. + */ +export default async function fetchText(filepath, options) { + const url = resolveAbsoluteUrl(filepath); + return fetch(url, options) + .then(r => r.text()); +} diff --git a/src/utils/object-properties.js b/src/utils/object-properties.js new file mode 100644 index 000000000..ed44cfdd6 --- /dev/null +++ b/src/utils/object-properties.js @@ -0,0 +1,50 @@ +/** + * 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 +*/ + +/* eslint-disable */ + +/** 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); + } +} diff --git a/src/utils/theme-settings.js b/src/utils/theme-settings.js index 22e45091d..57a96749f 100644 --- a/src/utils/theme-settings.js +++ b/src/utils/theme-settings.js @@ -23,7 +23,7 @@ export const themeSettingsState = { export const { baseUrl } = window; /** - * 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<{}>} */ diff --git a/tests/unit/App.spec.js b/tests/unit/App.spec.js index 61dc9b7a0..cc16bccc1 100644 --- a/tests/unit/App.spec.js +++ b/tests/unit/App.spec.js @@ -23,10 +23,17 @@ jest.mock('docc-render/utils/theme-settings', () => ({ getSetting: jest.fn(() => {}), })); +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomPageLoadScripts: jest.fn(), +})); + let App; + let fetchThemeSettings = jest.fn(); let getSetting = jest.fn(() => {}); +let runCustomPageLoadScripts = jest.fn(); + const matchMedia = { matches: false, addListener: jest.fn(), @@ -92,6 +99,7 @@ describe('App', () => { /* eslint-disable global-require */ App = require('docc-render/App.vue').default; ({ fetchThemeSettings } = require('docc-render/utils/theme-settings')); + ({ runCustomPageLoadScripts } = require('docc-render/utils/custom-scripts')); setThemeSetting({}); window.matchMedia = jest.fn().mockReturnValue(matchMedia); @@ -244,6 +252,12 @@ describe('App', () => { expect(wrapper.find(`#${AppTopID}`).exists()).toBe(true); }); + it('does not load "on-load" scripts immediately', () => { + // If "on-load" scripts are run immediately after creating or mounting the app, they will not + // have access to the dynamic documentation HTML for the initial route. + expect(runCustomPageLoadScripts).toHaveBeenCalledTimes(0); + }); + describe('Custom CSS Properties', () => { beforeEach(() => { setThemeSetting(LightDarkModeCSSSettings); diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js new file mode 100644 index 000000000..4f7dd0723 --- /dev/null +++ b/tests/unit/utils/custom-scripts.spec.js @@ -0,0 +1,149 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2022 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 +*/ + +/* eslint-disable no-eval */ + +let runCustomPageLoadScripts; +let runCustomNavigateScripts; + +let jsonMock; +let fetchMock; + +const textMock = jest.fn().mockResolvedValue(''); + +const createElementMock = jest.fn(document.createElement); +document.createElement = createElementMock; + +const evalMock = jest.fn(eval); +window.eval = evalMock; + +/** + * Sets the custom-scripts.json array fetched by the fetchMock. + * @param {object[]} customScripts + */ +function setCustomScripts(customScripts) { + // The jsonMock is different for each test, so it must be reset. + jsonMock = jest.fn().mockResolvedValue(customScripts); + + // The first call to the fetch function on each test will be to fetch custom-scripts.json. That's + // what the jsonMock is for. Any subsequent calls to fetch will be in the + // runCustomNavigateScripts tests, to fetch the contents of each script file. + fetchMock = jest.fn() + .mockResolvedValueOnce({ + ok: true, + json: jsonMock, + }).mockResolvedValue({ + ok: true, + text: textMock, + }); + + window.fetch = fetchMock; +} + +describe('custom-scripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.resetModules(); + // eslint-disable-next-line global-require + ({ runCustomPageLoadScripts, runCustomNavigateScripts } = require('@/utils/custom-scripts')); + }); + + describe('runCustomPageLoadScripts', () => { + it('creates a script element for each explicit or implicit "on-load" script', async () => { + setCustomScripts([ + { + url: 'https://www.example.js', + async: true, + run: 'on-load', + }, + { name: 'my-local-script' }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(2); + }); + + it('runs "on-load-and-navigate" scripts as well', async () => { + setCustomScripts([ + { + name: 'my-local-script.js', + run: 'on-load-and-navigate', + }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(1); + }); + + it('does not run "on-navigate" scripts', async () => { + setCustomScripts([ + { + name: 'my-local-script', + run: 'on-navigate', + }, + ]); + + await runCustomPageLoadScripts(); + + expect(createElementMock).toHaveBeenCalledTimes(0); + }); + }); + + describe('runCustomNavigateScripts', () => { + it('runs "on-navigate" and "on-load-and-navigate" scripts', async () => { + setCustomScripts([ + { + name: 'script1.js', + run: 'on-navigate', + }, + { + name: 'script2', + run: 'on-load-and-navigate', + }, + { + name: 'script3.js', + run: 'on-load-and-navigate', + }, + ]); + + await runCustomNavigateScripts(); + + // Unclear why this is necessary for runCustomNavigateScripts, especially since `await`ing + // runCustomPageLoadScripts works fine. + await new Promise(process.nextTick); + + expect(evalMock).toHaveBeenCalledTimes(3); + }); + + it('does not create script elements', async () => { + setCustomScripts([{ + name: 'my_script.js', + run: 'on-navigate', + }]); + + await runCustomNavigateScripts(); + await new Promise(process.nextTick); + + expect(createElementMock).toHaveBeenCalledTimes(0); + }); + + it('does not run scripts without a `run` property', async () => { + setCustomScripts([{ name: 'my-script' }]); + + await runCustomNavigateScripts(); + await new Promise(process.nextTick); + + expect(evalMock).toHaveBeenCalledTimes(0); + }); + }); +});