Skip to content

Commit

Permalink
Add support for custom scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucca-mito committed Oct 14, 2024
1 parent f7c7899 commit 7e92625
Show file tree
Hide file tree
Showing 7 changed files with 447 additions and 1 deletion.
26 changes: 26 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -70,6 +71,7 @@ export default {
return {
AppTopID,
appState: AppStore.state,
initialRoutingEventHasOccurred: false,
fromKeyboard: false,
isTargetIDE: process.env.VUE_APP_TARGET === 'ide',
themeSettings: themeSettingsState,
Expand Down Expand Up @@ -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) {
Expand Down
184 changes: 184 additions & 0 deletions src/utils/custom-scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* 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<void>}
*/
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);
}

export async function runCustomPageLoadScripts() {
await runCustomScripts(shouldRunOnPageLoad, addScriptElement);
}

export async function runCustomNavigateScripts() {
await runCustomScripts(shouldRunOnNavigate, evalScript);
}
23 changes: 23 additions & 0 deletions src/utils/fetch-text.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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());
}
50 changes: 50 additions & 0 deletions src/utils/object-properties.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
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 } = 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<{}>}
*/
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/App.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 7e92625

Please sign in to comment.