diff --git a/src/setup-utils/SwiftDocCRenderRouter.js b/src/setup-utils/SwiftDocCRenderRouter.js index 87ba2a4ac..b84026a76 100644 --- a/src/setup-utils/SwiftDocCRenderRouter.js +++ b/src/setup-utils/SwiftDocCRenderRouter.js @@ -9,6 +9,7 @@ */ import Router from 'vue-router'; +import AppStore from 'docc-render/stores/AppStore'; import { notFoundRouteName, serverErrorRouteName, @@ -21,6 +22,7 @@ import { import routes from 'docc-render/routes'; import { baseUrl } from 'docc-render/utils/theme-settings'; import { addPrefixedRoutes } from 'docc-render/utils/route-utils'; +import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts'; const defaultRoutes = [ ...routes, @@ -49,6 +51,15 @@ export default function createRouterInstance(routerConfig = {}) { restoreScrollOnReload(); }); + router.afterEach(async () => { + if (AppStore.state.firstRoutingEventHasOccurred) { + await runCustomNavigateScripts(); + } else { + await runCustomPageLoadScripts(); + AppStore.setFirstRoutingEventHasOccurred(true); + } + }); + if (process.env.VUE_APP_TARGET !== 'ide') { router.onError((error) => { const { route = { path: '/' } } = error; diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js index ca8231183..3e19b1265 100644 --- a/src/stores/AppStore.js +++ b/src/stores/AppStore.js @@ -30,6 +30,7 @@ export default { supportsAutoColorScheme, systemColorScheme: ColorScheme.light, availableLocales: [], + firstRoutingEventHasOccurred: false, }, reset() { this.state.imageLoadingStrategy = process.env.VUE_APP_TARGET === 'ide' @@ -37,6 +38,7 @@ export default { this.state.preferredColorScheme = Settings.preferredColorScheme || defaultColorScheme; this.state.supportsAutoColorScheme = supportsAutoColorScheme; this.state.systemColorScheme = ColorScheme.light; + this.state.firstRoutingEventHasOccurred = false; }, setImageLoadingStrategy(strategy) { this.state.imageLoadingStrategy = strategy; @@ -59,6 +61,9 @@ export default { setSystemColorScheme(value) { this.state.systemColorScheme = value; }, + setFirstRoutingEventHasOccurred(hasOccurred) { + this.state.firstRoutingEventHasOccurred = hasOccurred; + }, syncPreferredColorScheme() { if (!!Settings.preferredColorScheme && Settings.preferredColorScheme !== this.state.preferredColorScheme) { diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js new file mode 100644 index 000000000..fd400015b --- /dev/null +++ b/src/utils/custom-scripts.js @@ -0,0 +1,210 @@ +/** + * 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'; + +/** 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]); +} + +/** + * 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; + + // Dynamically-created script elements are `async` by default. But we don't want custom + // scripts to be implicitly async, because if a documentation author adds `defer` to some or + // all of their custom scripts (meaning that they want the execution order of those scripts to + // be deterministic), then the author's `defer` will be overriden by the implicit `async`, + // meaning that the execution order will be unexpectedly nondeterministic. + // + // Therefore, remove the script element's `async` unless async is explicitly enabled. + scriptElement.async = customScript.async || false; + + copyPresentProperties(['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); + scriptElement.async = customScript.async || false; + + 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 `new Function`, which is essentially `eval` but without exposing + * local variables. 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-new-func + new Function(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..a0c32d7d9 --- /dev/null +++ b/src/utils/object-properties.js @@ -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); + } +} 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/components/ContentNode/Reference.spec.js b/tests/unit/components/ContentNode/Reference.spec.js index 9f8c62659..71e8539f7 100644 --- a/tests/unit/components/ContentNode/Reference.spec.js +++ b/tests/unit/components/ContentNode/Reference.spec.js @@ -23,7 +23,9 @@ import { TopicRole } from '@/constants/roles'; const router = createRouterInstance(); const localVue = createLocalVue(); localVue.use(Router); + window.scrollTo = () => ({}); +window.fetch = jest.fn().mockResolvedValue({}); describe('Reference', () => { it('renders a `ReferenceExternal` for external urls', () => { diff --git a/tests/unit/imagePreloading/imagePreloading.spec.js b/tests/unit/imagePreloading/imagePreloading.spec.js index ff02d8c3a..84866cf24 100644 --- a/tests/unit/imagePreloading/imagePreloading.spec.js +++ b/tests/unit/imagePreloading/imagePreloading.spec.js @@ -27,7 +27,9 @@ const router = createRouterInstance(); jest.mock('docc-render/utils/theme-settings'); const topicData = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'tutorial.json'))); + window.scrollTo = () => ({}); +window.fetch = jest.fn().mockResolvedValue({}); const localVue = createLocalVue(); localVue.directive('hide', hide); diff --git a/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js b/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js index 5d6df4b34..7e756fc6e 100644 --- a/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js +++ b/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js @@ -22,6 +22,7 @@ const mockInstance = { onReady: jest.fn(), replace: jest.fn(), beforeEach: jest.fn(), + afterEach: jest.fn(), }; jest.mock('vue-router', () => jest.fn(() => (mockInstance))); diff --git a/tests/unit/stores/AppStore.spec.js b/tests/unit/stores/AppStore.spec.js index e961c1e55..fcfa51843 100644 --- a/tests/unit/stores/AppStore.spec.js +++ b/tests/unit/stores/AppStore.spec.js @@ -22,6 +22,7 @@ describe('AppStore', () => { systemColorScheme: ColorScheme.light, preferredLocale: null, availableLocales: [], + firstRoutingEventHasOccurred: false, }); }); @@ -37,6 +38,7 @@ describe('AppStore', () => { systemColorScheme: ColorScheme.light, preferredLocale: null, availableLocales: [], + firstRoutingEventHasOccurred: false, }); // restore target @@ -65,6 +67,13 @@ describe('AppStore', () => { }); }); + describe('setFirstRoutingEventHasOccurred', () => { + it('sets the `firstRoutingEventHasOccurred` state', () => { + AppStore.setFirstRoutingEventHasOccurred(true); + expect(AppStore.state.firstRoutingEventHasOccurred).toBe(true); + }); + }); + describe('syncPreferredColorScheme', () => { it('sets the `syncPreferredColorScheme` state', () => { AppStore.syncPreferredColorScheme(); @@ -86,6 +95,7 @@ describe('AppStore', () => { systemColorScheme: ColorScheme.light, preferredLocale: null, availableLocales: [], + firstRoutingEventHasOccurred: false, }); }); }); diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js new file mode 100644 index 000000000..c8b51a2c3 --- /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; + +// Spy window.Function to check that navigation scripts are executed. +const functionSpy = jest.spyOn(window, 'Function'); + +/** + * 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(functionSpy).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(functionSpy).toHaveBeenCalledTimes(0); + }); + }); +});