From 868b373e10d3036e88efc8a48b666d036ffc655d Mon Sep 17 00:00:00 2001 From: Lucca de Mello Date: Thu, 19 Sep 2024 21:04:09 -0400 Subject: [PATCH 1/4] Add support for custom scripts --- src/mixins/communicationBridgeUtils.js | 11 + src/utils/custom-scripts.js | 196 +++++++++++++++ src/utils/object-properties.js | 48 ++++ src/utils/theme-settings.js | 2 +- .../imagePreloading/imagePreloading.spec.js | 5 + .../mixins/communicationBridgeUtils.spec.js | 55 +++++ tests/unit/utils/custom-scripts.spec.js | 225 ++++++++++++++++++ tests/unit/views/DocumentationTopic.spec.js | 5 + tests/unit/views/Topic.spec.js | 5 + tests/unit/views/TutorialsOverview.spec.js | 5 + 10 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 src/utils/custom-scripts.js create mode 100644 src/utils/object-properties.js create mode 100644 tests/unit/mixins/communicationBridgeUtils.spec.js create mode 100644 tests/unit/utils/custom-scripts.spec.js diff --git a/src/mixins/communicationBridgeUtils.js b/src/mixins/communicationBridgeUtils.js index 4f1bc9a00..7b83254fd 100644 --- a/src/mixins/communicationBridgeUtils.js +++ b/src/mixins/communicationBridgeUtils.js @@ -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: { @@ -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 diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js new file mode 100644 index 000000000..4525e53a2 --- /dev/null +++ b/src/utils/custom-scripts.js @@ -0,0 +1,196 @@ +/** + * 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]); +} + +/** + * 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. + */ +function rerunScriptElement(customScript) { + const scriptElement = createScriptElement(customScript); + + const removeScriptElement = () => scriptElement.remove(); + scriptElement.addEventListener('load', removeScriptElement, { once: true }); + scriptElement.addEventListener('error', removeScriptElement, { once: true }); + + document.head.appendChild(scriptElement); +} + +/** + * 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, rerunScriptElement); +} + +/** 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} + */ +export async function runCustomScriptsForNewContent() { + if (hasRunPageLoadScripts) { + await runCustomNavigateScripts(); + } else { + hasRunPageLoadScripts = true; + await runCustomPageLoadScripts(); + } +} 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 fad2c8145..4468210da 100644 --- a/src/utils/theme-settings.js +++ b/src/utils/theme-settings.js @@ -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<{}>} */ diff --git a/tests/unit/imagePreloading/imagePreloading.spec.js b/tests/unit/imagePreloading/imagePreloading.spec.js index cd9e0fc6f..e9294469d 100644 --- a/tests/unit/imagePreloading/imagePreloading.spec.js +++ b/tests/unit/imagePreloading/imagePreloading.spec.js @@ -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'); diff --git a/tests/unit/mixins/communicationBridgeUtils.spec.js b/tests/unit/mixins/communicationBridgeUtils.spec.js new file mode 100644 index 000000000..b417fad71 --- /dev/null +++ b/tests/unit/mixins/communicationBridgeUtils.spec.js @@ -0,0 +1,55 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2026 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 { shallowMount } from '@vue/test-utils'; +import communicationBridgeUtils from 'docc-render/mixins/communicationBridgeUtils'; +import { runCustomScriptsForNewContent } from 'docc-render/utils/custom-scripts'; + +jest.mock('docc-render/utils/custom-scripts', () => ({ + runCustomScriptsForNewContent: jest.fn().mockResolvedValue(), +})); + +const createWrapper = topicData => shallowMount({ + mixins: [communicationBridgeUtils], + data: () => ({ topicData }), + render: () => null, +}, { + mocks: { + $bridge: { send: jest.fn() }, + }, +}); + +describe('communicationBridgeUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('newContentMounted', () => { + it('sends a "rendered" message to the bridge', () => { + const wrapper = createWrapper({ foo: 'bar' }); + wrapper.vm.newContentMounted(); + expect(wrapper.vm.$bridge.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'rendered', + })); + }); + + it('runs custom scripts for the new content', () => { + const wrapper = createWrapper({ foo: 'bar' }); + wrapper.vm.newContentMounted(); + expect(runCustomScriptsForNewContent).toHaveBeenCalledTimes(1); + }); + + it('does not run custom scripts before there is any content', () => { + const wrapper = createWrapper(null); + wrapper.vm.newContentMounted(); + expect(runCustomScriptsForNewContent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js new file mode 100644 index 000000000..04539d89a --- /dev/null +++ b/tests/unit/utils/custom-scripts.spec.js @@ -0,0 +1,225 @@ +/** + * 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 +*/ + +import { + runCustomPageLoadScripts, + runCustomNavigateScripts, + runCustomScriptsForNewContent, +} from 'docc-render/utils/custom-scripts'; + +let jsonMock; +let fetchMock; + +const createElementMock = jest.fn(document.createElement); +document.createElement = createElementMock; + +// Spy on document.head.appendChild to check which script elements are added, even after they're +// removed again. +const appendChildSpy = jest.spyOn(document.head, 'appendChild'); + +/** + * 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); + + fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: jsonMock, + }); + + window.fetch = fetchMock; +} + +/** + * Returns the script elements that were added to the document's head, in the order in which they + * were added. + * @returns {HTMLScriptElement[]} + */ +function addedScriptElements() { + return appendChildSpy.mock.calls.map(([element]) => element); +} + +describe('custom-scripts', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.resetModules(); + + document.head.innerHTML = ''; + }); + + 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); + }); + + it('configures script elements from the custom scripts', async () => { + setCustomScripts([ + { url: 'https://www.example.com/external.js', integrity: 'sha384-external' }, + { name: 'my-local-script' }, + { name: 'my-async-script.js', async: true }, + ]); + + await runCustomPageLoadScripts(); + + const [external, local, asyncScript] = addedScriptElements(); + + expect(external.src).toBe('https://www.example.com/external.js'); + expect(external.integrity).toBe('sha384-external'); + expect(external.crossOrigin).toBe('anonymous'); + expect(external.async).toBe(false); + + expect(local.src).toMatch(/\/custom-scripts\/my-local-script\.js$/); + expect(local.async).toBe(false); + + expect(asyncScript.src).toMatch(/\/custom-scripts\/my-async-script\.js$/); + expect(asyncScript.async).toBe(true); + }); + }); + + 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(); + + expect(addedScriptElements()).toHaveLength(3); + }); + + it('adds the scripts in the order in which they are listed, without `async`', async () => { + setCustomScripts([ + { name: 'first.js', run: 'on-navigate' }, + { url: 'https://www.example.com/second.js', run: 'on-navigate' }, + ]); + + await runCustomNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements.map(element => element.src)).toEqual([ + expect.stringMatching(/\/custom-scripts\/first\.js$/), + 'https://www.example.com/second.js', + ]); + // Script elements that aren't `async` run in the order in which they're added. + expect(scriptElements.map(element => element.async)).toEqual([false, false]); + }); + + it('adds a new script element each time the reader navigates', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + + await runCustomNavigateScripts(); + await runCustomNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(2); + expect(scriptElements[0]).not.toBe(scriptElements[1]); + expect(scriptElements[0].src).toBe(scriptElements[1].src); + }); + + it('removes each script element once its script has run or failed to load', async () => { + setCustomScripts([ + { name: 'loads.js', run: 'on-navigate' }, + { name: 'fails.js', run: 'on-navigate' }, + ]); + + await runCustomNavigateScripts(); + + const [loads, fails] = addedScriptElements(); + expect(document.head.contains(loads)).toBe(true); + expect(document.head.contains(fails)).toBe(true); + + loads.dispatchEvent(new Event('load')); + fails.dispatchEvent(new Event('error')); + + expect(document.head.contains(loads)).toBe(false); + expect(document.head.contains(fails)).toBe(false); + }); + + it('does not run scripts without a `run` property', async () => { + setCustomScripts([{ name: 'my-script' }]); + + await runCustomNavigateScripts(); + + expect(addedScriptElements()).toHaveLength(0); + }); + }); + + describe('runCustomScriptsForNewContent', () => { + it('runs the page load scripts the first time, and the navigation scripts after that', async () => { + setCustomScripts([ + { name: 'on-load.js' }, + { name: 'on-navigate.js', run: 'on-navigate' }, + ]); + + await runCustomScriptsForNewContent(); + expect(addedScriptElements().map(element => element.src)).toEqual([ + expect.stringMatching(/\/on-load\.js$/), + ]); + + await runCustomScriptsForNewContent(); + expect(addedScriptElements().map(element => element.src)).toEqual([ + expect.stringMatching(/\/on-load\.js$/), + expect.stringMatching(/\/on-navigate\.js$/), + ]); + }); + }); +}); diff --git a/tests/unit/views/DocumentationTopic.spec.js b/tests/unit/views/DocumentationTopic.spec.js index 578edb3ba..c20cd9dae 100644 --- a/tests/unit/views/DocumentationTopic.spec.js +++ b/tests/unit/views/DocumentationTopic.spec.js @@ -19,6 +19,11 @@ import onThisPageRegistrator from '@/mixins/onThisPageRegistrator'; import { getSetting } from 'docc-render/utils/theme-settings'; import { flushPromises } from '../../../test-utils'; +// 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(), +})); + jest.mock('docc-render/mixins/onThisPageRegistrator'); jest.mock('docc-render/utils/theme-settings'); diff --git a/tests/unit/views/Topic.spec.js b/tests/unit/views/Topic.spec.js index 35caceeb6..f0ed5e01d 100644 --- a/tests/unit/views/Topic.spec.js +++ b/tests/unit/views/Topic.spec.js @@ -16,6 +16,11 @@ import Tutorial from 'docc-render/components/Tutorial.vue'; import onPageLoadScrollToFragment from 'docc-render/mixins/onPageLoadScrollToFragment'; import { fetchDataForRouteEnter } from '@/utils/data'; +// 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(), +})); + jest.mock('docc-render/mixins/onPageLoadScrollToFragment'); jest.mock('@/utils/data'); diff --git a/tests/unit/views/TutorialsOverview.spec.js b/tests/unit/views/TutorialsOverview.spec.js index 5812258fb..b05b2371e 100644 --- a/tests/unit/views/TutorialsOverview.spec.js +++ b/tests/unit/views/TutorialsOverview.spec.js @@ -12,6 +12,11 @@ import { shallowMount } from '@vue/test-utils'; import TutorialsOverview from 'docc-render/views/TutorialsOverview.vue'; import onPageLoadScrollToFragment from 'docc-render/mixins/onPageLoadScrollToFragment'; +// 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(), +})); + jest.mock('docc-render/mixins/onPageLoadScrollToFragment'); const { Overview } = TutorialsOverview.components; From 1788a9f0a2da57481c0ad9bcf067329425d00e31 Mon Sep 17 00:00:00 2001 From: Lucca de Mello Date: Sat, 24 Oct 2026 14:59:35 -0400 Subject: [PATCH 2/4] Await custom navigation scripts and isolate failures `runCustomNavigateScripts` returned as soon as it had added the script elements, without waiting for the scripts to run, and an invalid custom script stopped the remaining scripts from running. Wait for each navigation script to run or fail to load before resolving, and report invalid scripts and scripts that fail to load in the console instead of letting them prevent the remaining scripts from running, both on page load and on navigation. --- src/utils/custom-scripts.js | 74 +++++++++++++---- tests/unit/utils/custom-scripts.spec.js | 106 ++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js index 4525e53a2..6a01d1c32 100644 --- a/src/utils/custom-scripts.js +++ b/src/utils/custom-scripts.js @@ -68,6 +68,15 @@ function urlGivenScriptName(customScriptName) { 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. @@ -127,31 +136,39 @@ function addScriptElement(customScript) { * 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} A promise that resolves once the script has run, or rejects if the + * custom script is invalid or fails to load. */ function rerunScriptElement(customScript) { - const scriptElement = createScriptElement(customScript); - - const removeScriptElement = () => scriptElement.remove(); - scriptElement.addEventListener('load', removeScriptElement, { once: true }); - scriptElement.addEventListener('error', removeScriptElement, { once: true }); - - document.head.appendChild(scriptElement); + 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); + }); } /** - * Run all custom scripts that pass the `predicate` using the `executor`. - * @param {(customScript: object) => boolean} predicate - * @param {(customScript: object) => void} executor - * @returns {Promise} + * Fetches the custom scripts declared in custom-scripts.json. + * @returns {Promise} The custom scripts, or an empty array if the file is absent. */ -async function runCustomScripts(predicate, executor) { +async function fetchCustomScripts() { 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; + return []; } const customScripts = await response.json(); @@ -159,7 +176,7 @@ async function runCustomScripts(predicate, executor) { throw new Error(`Content of ${customScriptsFileName} should be an array.`); } - customScripts.filter(predicate).forEach(executor); + return customScripts; } /** @@ -167,15 +184,38 @@ async function runCustomScripts(predicate, executor) { * @returns {Promise} */ export async function runCustomPageLoadScripts() { - await runCustomScripts(shouldRunOnPageLoad, addScriptElement); + 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} + * @returns {Promise} A promise that resolves once all the scripts have run or failed. */ export async function runCustomNavigateScripts() { - await runCustomScripts(shouldRunOnNavigate, rerunScriptElement); + 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. */ diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js index 04539d89a..c6152db18 100644 --- a/tests/unit/utils/custom-scripts.spec.js +++ b/tests/unit/utils/custom-scripts.spec.js @@ -49,15 +49,46 @@ function addedScriptElements() { return appendChildSpy.mock.calls.map(([element]) => element); } +/** + * Waits for pending promises, like the mocked fetch of custom-scripts.json, to settle. + * @returns {Promise} + */ +function flushPromises() { + return new Promise((resolve) => { + setTimeout(resolve); + }); +} + +/** + * Runs the navigation scripts, simulating each added script element loading, since jsdom doesn't + * load scripts. + * @param {(element: HTMLScriptElement) => string} eventType The event to dispatch on each element. + * @returns {Promise} + */ +async function runNavigateScripts(eventType = () => 'load') { + const promise = runCustomNavigateScripts(); + await flushPromises(); + addedScriptElements().forEach(element => element.dispatchEvent(new Event(eventType(element)))); + return promise; +} + describe('custom-scripts', () => { + let consoleErrorSpy; + beforeEach(() => { jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.resetModules(); document.head.innerHTML = ''; }); + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + describe('runCustomPageLoadScripts', () => { it('creates a script element for each explicit or implicit "on-load" script', async () => { setCustomScripts([ @@ -122,6 +153,23 @@ describe('custom-scripts', () => { expect(asyncScript.src).toMatch(/\/custom-scripts\/my-async-script\.js$/); expect(asyncScript.async).toBe(true); }); + + it('skips invalid scripts, adding the other scripts', async () => { + setCustomScripts([ + // A script must have either a `url` or a `name`. + { async: true }, + // A script can't have both a `url` and a `name`. + { url: 'https://www.example.com/script.js', name: 'my-local-script' }, + { name: 'my-other-local-script' }, + ]); + + await runCustomPageLoadScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(1); + expect(scriptElements[0].src).toMatch(/\/custom-scripts\/my-other-local-script\.js$/); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); }); describe('runCustomNavigateScripts', () => { @@ -141,7 +189,7 @@ describe('custom-scripts', () => { }, ]); - await runCustomNavigateScripts(); + await runNavigateScripts(); expect(addedScriptElements()).toHaveLength(3); }); @@ -152,7 +200,7 @@ describe('custom-scripts', () => { { url: 'https://www.example.com/second.js', run: 'on-navigate' }, ]); - await runCustomNavigateScripts(); + await runNavigateScripts(); const scriptElements = addedScriptElements(); expect(scriptElements.map(element => element.src)).toEqual([ @@ -166,8 +214,8 @@ describe('custom-scripts', () => { it('adds a new script element each time the reader navigates', async () => { setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); - await runCustomNavigateScripts(); - await runCustomNavigateScripts(); + await runNavigateScripts(); + await runNavigateScripts(); const scriptElements = addedScriptElements(); expect(scriptElements).toHaveLength(2); @@ -181,7 +229,8 @@ describe('custom-scripts', () => { { name: 'fails.js', run: 'on-navigate' }, ]); - await runCustomNavigateScripts(); + const promise = runCustomNavigateScripts(); + await flushPromises(); const [loads, fails] = addedScriptElements(); expect(document.head.contains(loads)).toBe(true); @@ -189,11 +238,52 @@ describe('custom-scripts', () => { loads.dispatchEvent(new Event('load')); fails.dispatchEvent(new Event('error')); + await promise; expect(document.head.contains(loads)).toBe(false); expect(document.head.contains(fails)).toBe(false); }); + it('waits for the scripts to run before resolving', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + + let hasResolved = false; + const promise = runCustomNavigateScripts().then(() => { hasResolved = true; }); + await flushPromises(); + expect(hasResolved).toBe(false); + + addedScriptElements()[0].dispatchEvent(new Event('load')); + await promise; + expect(hasResolved).toBe(true); + }); + + it('runs the other scripts when one of them fails to load', async () => { + setCustomScripts([ + { name: 'missing.js', run: 'on-navigate' }, + { name: 'present.js', run: 'on-navigate' }, + ]); + + await runNavigateScripts(element => (element.src.endsWith('missing.js') ? 'error' : 'load')); + + expect(addedScriptElements()).toHaveLength(2); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('runs the other scripts when one of them is invalid', async () => { + setCustomScripts([ + // A script must have either a `url` or a `name`. + { run: 'on-navigate' }, + { name: 'valid.js', run: 'on-navigate' }, + ]); + + await runNavigateScripts(); + + const scriptElements = addedScriptElements(); + expect(scriptElements).toHaveLength(1); + expect(scriptElements[0].src).toMatch(/\/custom-scripts\/valid\.js$/); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + it('does not run scripts without a `run` property', async () => { setCustomScripts([{ name: 'my-script' }]); @@ -215,7 +305,11 @@ describe('custom-scripts', () => { expect.stringMatching(/\/on-load\.js$/), ]); - await runCustomScriptsForNewContent(); + // Navigation scripts are awaited, so simulate the script loading. + const promise = runCustomScriptsForNewContent(); + await flushPromises(); + addedScriptElements()[1].dispatchEvent(new Event('load')); + await promise; expect(addedScriptElements().map(element => element.src)).toEqual([ expect.stringMatching(/\/on-load\.js$/), expect.stringMatching(/\/on-navigate\.js$/), From f2989dfcd01d1d3d9c1695024e04e799ceb16293 Mon Sep 17 00:00:00 2001 From: Lucca de Mello Date: Sat, 24 Oct 2026 18:03:00 -0400 Subject: [PATCH 3/4] Add empty custom-scripts.json to template output The renderer requests custom-scripts.json on page load and on each navigation, which results in a 404 for documentation without custom scripts. Include an empty custom-scripts.json in the template, like theme-settings.json, so that the request always succeeds. DocC replaces it with the catalog's custom-scripts.json, if there is one. --- app/public/custom-scripts.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/public/custom-scripts.json diff --git a/app/public/custom-scripts.json b/app/public/custom-scripts.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/app/public/custom-scripts.json @@ -0,0 +1 @@ +[] From 5377c78e23471282a56e6e761de620a019a62371 Mon Sep 17 00:00:00 2001 From: Lucca de Mello Date: Sat, 24 Oct 2026 18:11:59 -0400 Subject: [PATCH 4/4] Fetch custom-scripts.json only once The renderer requested custom-scripts.json on page load and again on every navigation. Fetch it once and reuse the result, like theme-settings.json. If the request fails, the next routing event tries again. --- src/utils/custom-scripts.js | 22 ++++++++++-- tests/unit/utils/custom-scripts.spec.js | 45 ++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/utils/custom-scripts.js b/src/utils/custom-scripts.js index 6a01d1c32..d78aac5a5 100644 --- a/src/utils/custom-scripts.js +++ b/src/utils/custom-scripts.js @@ -158,10 +158,10 @@ function rerunScriptElement(customScript) { } /** - * Fetches the custom scripts declared in custom-scripts.json. + * Fetches the custom scripts declared in custom-scripts.json from the server. * @returns {Promise} The custom scripts, or an empty array if the file is absent. */ -async function fetchCustomScripts() { +async function fetchCustomScriptsFromServer() { const customScriptsFileName = 'custom-scripts.json'; const url = resolveAbsoluteUrl(`/${customScriptsFileName}`); @@ -179,6 +179,24 @@ async function fetchCustomScripts() { 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} 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} diff --git a/tests/unit/utils/custom-scripts.spec.js b/tests/unit/utils/custom-scripts.spec.js index c6152db18..210153e0a 100644 --- a/tests/unit/utils/custom-scripts.spec.js +++ b/tests/unit/utils/custom-scripts.spec.js @@ -8,11 +8,9 @@ * See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -import { - runCustomPageLoadScripts, - runCustomNavigateScripts, - runCustomScriptsForNewContent, -} from 'docc-render/utils/custom-scripts'; +let runCustomPageLoadScripts; +let runCustomNavigateScripts; +let runCustomScriptsForNewContent; let jsonMock; let fetchMock; @@ -80,7 +78,15 @@ describe('custom-scripts', () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // The module caches custom-scripts.json, so load a fresh copy of it for each test. jest.resetModules(); + /* eslint-disable global-require */ + ({ + runCustomPageLoadScripts, + runCustomNavigateScripts, + runCustomScriptsForNewContent, + } = require('docc-render/utils/custom-scripts')); + /* eslint-enable global-require */ document.head.innerHTML = ''; }); @@ -316,4 +322,33 @@ describe('custom-scripts', () => { ]); }); }); + + describe('fetching custom-scripts.json', () => { + it('only requests the file once', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-load-and-navigate' }]); + + await runCustomPageLoadScripts(); + await runNavigateScripts(); + await runNavigateScripts(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + // The script still runs on page load and on each navigation. + expect(addedScriptElements()).toHaveLength(3); + }); + + it('requests the file again after failing to fetch it', async () => { + setCustomScripts([{ name: 'my-script.js', run: 'on-navigate' }]); + const successfulFetch = fetchMock; + fetchMock = jest.fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockImplementation(successfulFetch); + window.fetch = fetchMock; + + await expect(runCustomNavigateScripts()).rejects.toThrow('Failed to fetch'); + await runNavigateScripts(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addedScriptElements()).toHaveLength(1); + }); + }); });