Skip to content

Commit 7e92625

Browse files
committed
Add support for custom scripts
1 parent f7c7899 commit 7e92625

File tree

7 files changed

+447
-1
lines changed

7 files changed

+447
-1
lines changed

src/App.vue

+26
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import ColorScheme from 'docc-render/constants/ColorScheme';
4646
import Footer from 'docc-render/components/Footer.vue';
4747
import InitialLoadingPlaceholder from 'docc-render/components/InitialLoadingPlaceholder.vue';
4848
import { baseNavStickyAnchorId } from 'docc-render/constants/nav';
49+
import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts';
4950
import { fetchThemeSettings, themeSettingsState, getSetting } from 'docc-render/utils/theme-settings';
5051
import { objectToCustomProperties } from 'docc-render/utils/themes';
5152
import { AppTopID } from 'docc-render/constants/AppTopID';
@@ -70,6 +71,7 @@ export default {
7071
return {
7172
AppTopID,
7273
appState: AppStore.state,
74+
initialRoutingEventHasOccurred: false,
7375
fromKeyboard: false,
7476
isTargetIDE: process.env.VUE_APP_TARGET === 'ide',
7577
themeSettings: themeSettingsState,
@@ -107,6 +109,30 @@ export default {
107109
},
108110
},
109111
watch: {
112+
async $route() {
113+
// A routing event has just occurred, which is either the initial page load or a subsequent
114+
// navigation. So load any custom scripts that should be run, based on their `run` property,
115+
// after this routing event.
116+
//
117+
// This hook, and (as a result) any appropriate custom scripts for the current routing event,
118+
// are called *after* the HTML for the current route has been dynamically added to the DOM.
119+
// This means that custom scripts have access to the documentation HTML for the current
120+
// topic (or tutorial, etc).
121+
122+
if (this.initialRoutingEventHasOccurred) {
123+
// The initial page load counts as a routing event, so we only want to run "on-navigate"
124+
// scripts from the second routing event onward.
125+
await runCustomNavigateScripts();
126+
} else {
127+
// The "on-load" scripts are run here (on the routing hook), not on `created` or `mounted`,
128+
// so that the scripts have access to the dynamically-added documentation HTML for the
129+
// current topic.
130+
await runCustomPageLoadScripts();
131+
132+
// The next time we enter the routing hook, run the navigation scripts.
133+
this.initialRoutingEventHasOccurred = true;
134+
}
135+
},
110136
CSSCustomProperties: {
111137
immediate: true,
112138
handler(CSSCustomProperties) {

src/utils/custom-scripts.js

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* This source file is part of the Swift.org open source project
3+
*
4+
* Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
* Licensed under Apache License v2.0 with Runtime Library Exception
6+
*
7+
* See https://swift.org/LICENSE.txt for license information
8+
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import fetchText from 'docc-render/utils/fetch-text';
12+
import {
13+
copyPresentProperties,
14+
copyPropertyIfPresent,
15+
has,
16+
mustNotHave,
17+
} from 'docc-render/utils/object-properties';
18+
import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';
19+
20+
/**
21+
* Returns whether the custom script should be run when the reader navigates to a subpage.
22+
* @param {object} customScript
23+
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
24+
* "on-load" or "on-load-and-navigate". Also returns true if the `run` property is absent.
25+
*/
26+
function shouldRunOnPageLoad(customScript) {
27+
return !has(customScript, 'run')
28+
|| customScript.run === 'on-load' || customScript.run === 'on-load-and-navigate';
29+
}
30+
31+
/**
32+
* Returns whether the custom script should be run when the reader navigates to a topic.
33+
* @param {object} customScript
34+
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
35+
* "on-navigate" or "on-load-and-navigate".
36+
*/
37+
function shouldRunOnNavigate(customScript) {
38+
return has(customScript, 'run')
39+
&& (customScript.run === 'on-navigate' || customScript.run === 'on-load-and-navigate');
40+
}
41+
42+
/**
43+
* Gets the URL for a local custom script given its name.
44+
* @param {string} customScriptName The name of the custom script as spelled in
45+
* custom-scripts.json. While the actual filename (in the custom-scripts directory) is always
46+
* expected to end in ".js", the name in custom-scripts.json may or may not include the ".js"
47+
* extension.
48+
* @returns {string} The absolute URL where the script is, accounting for baseURL.
49+
* @example
50+
* // if baseURL if '/foo'
51+
* urlGivenScriptName('hello-world') // http://localhost:8080/foo/hello-world.js
52+
* urlGivenScriptName('hello-world.js') // http://localhost:8080/foo/hello-world.js
53+
*/
54+
function urlGivenScriptName(customScriptName) {
55+
let scriptNameWithExtension = customScriptName;
56+
57+
// If the provided name does not already include the ".js" extension, add it.
58+
if (customScriptName.slice(-3) !== '.js') {
59+
scriptNameWithExtension = `${customScriptName}.js`;
60+
}
61+
62+
return resolveAbsoluteUrl(['', 'custom-scripts', scriptNameWithExtension]);
63+
}
64+
65+
/**
66+
* Add an HTMLScriptElement containing the custom script to the document's head, which runs the
67+
* script on page load.
68+
* @param {object} customScript The custom script, assuming it should be run on page load.
69+
*/
70+
function addScriptElement(customScript) {
71+
const scriptElement = document.createElement('script');
72+
73+
copyPropertyIfPresent('type', customScript, scriptElement);
74+
75+
if (has(customScript, 'url')) {
76+
mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
77+
mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
78+
79+
scriptElement.src = customScript.url;
80+
81+
copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement);
82+
83+
// If `integrity` is set on an external script, then CORS must be enabled as well.
84+
if (has(customScript, 'integrity')) {
85+
scriptElement.crossOrigin = 'anonymous';
86+
}
87+
} else if (has(customScript, 'name')) {
88+
mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
89+
90+
scriptElement.src = urlGivenScriptName(customScript.name);
91+
92+
copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement);
93+
} else if (has(customScript, 'code')) {
94+
mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
95+
mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
96+
mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
97+
98+
scriptElement.innerHTML = customScript.code;
99+
} else {
100+
throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
101+
}
102+
103+
document.head.appendChild(scriptElement);
104+
}
105+
106+
/**
107+
* Run the custom script using `eval`. Useful for running a custom script anytime after page load,
108+
* namely when the reader navigates to a subpage.
109+
* @param {object} customScript The custom script, assuming it should be run on navigate.
110+
*/
111+
async function evalScript(customScript) {
112+
let codeToEval;
113+
114+
if (has(customScript, 'url')) {
115+
mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
116+
mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
117+
118+
if (has(customScript, 'integrity')) {
119+
// External script with integrity. Must also use CORS.
120+
codeToEval = await fetchText(customScript.url, {
121+
integrity: customScript.integrity,
122+
crossOrigin: 'anonymous',
123+
});
124+
} else {
125+
// External script without integrity.
126+
codeToEval = await fetchText(customScript.url);
127+
}
128+
} else if (has(customScript, 'name')) {
129+
mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
130+
131+
const url = urlGivenScriptName(customScript.name);
132+
133+
if (has(customScript, 'integrity')) {
134+
// Local script with integrity. Do not use CORS.
135+
codeToEval = await fetchText(url, { integrity: customScript.integrity });
136+
} else {
137+
// Local script without integrity.
138+
codeToEval = await fetchText(url);
139+
}
140+
} else if (has(customScript, 'code')) {
141+
mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
142+
mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
143+
mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
144+
145+
codeToEval = customScript.code;
146+
} else {
147+
throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
148+
}
149+
150+
// eslint-disable-next-line no-eval
151+
eval(codeToEval);
152+
}
153+
154+
/**
155+
* Run all custom scripts that pass the `predicate` using the `executor`.
156+
* @param {(customScript: object) => boolean} predicate
157+
* @param {(customScript: object) => void} executor
158+
* @returns {Promise<void>}
159+
*/
160+
async function runCustomScripts(predicate, executor) {
161+
const customScriptsFileName = 'custom-scripts.json';
162+
const url = resolveAbsoluteUrl(`/${customScriptsFileName}`);
163+
164+
const response = await fetch(url);
165+
if (!response.ok) {
166+
// If the file is absent, fail silently.
167+
return;
168+
}
169+
170+
const customScripts = await response.json();
171+
if (!Array.isArray(customScripts)) {
172+
throw new Error(`Content of ${customScriptsFileName} should be an array.`);
173+
}
174+
175+
customScripts.filter(predicate).forEach(executor);
176+
}
177+
178+
export async function runCustomPageLoadScripts() {
179+
await runCustomScripts(shouldRunOnPageLoad, addScriptElement);
180+
}
181+
182+
export async function runCustomNavigateScripts() {
183+
await runCustomScripts(shouldRunOnNavigate, evalScript);
184+
}

src/utils/fetch-text.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* This source file is part of the Swift.org open source project
3+
*
4+
* Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
* Licensed under Apache License v2.0 with Runtime Library Exception
6+
*
7+
* See https://swift.org/LICENSE.txt for license information
8+
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';
12+
13+
/**
14+
* Fetch the contents of a file as text.
15+
* @param {string} filepath The file path.
16+
* @param {RequestInit?} options Optional request settings.
17+
* @returns {Promise<string>} The text contents of the file.
18+
*/
19+
export default async function fetchText(filepath, options) {
20+
const url = resolveAbsoluteUrl(filepath);
21+
return fetch(url, options)
22+
.then(r => r.text());
23+
}

src/utils/object-properties.js

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* This source file is part of the Swift.org open source project
3+
*
4+
* Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
* Licensed under Apache License v2.0 with Runtime Library Exception
6+
*
7+
* See https://swift.org/LICENSE.txt for license information
8+
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
/* eslint-disable */
12+
13+
/** Convenient shorthand for `Object.hasOwn`. */
14+
export const has = Object.hasOwn;
15+
/**
16+
* Copies source.property, if it exists, to destination.property.
17+
* @param {string} property
18+
* @param {object} source
19+
* @param {object} destination
20+
*/
21+
export function copyPropertyIfPresent(property, source, destination) {
22+
if (has(source, property)) {
23+
// eslint-disable-next-line no-param-reassign
24+
destination[property] = source[property];
25+
}
26+
}
27+
28+
/**
29+
* Copies all specified properties present in the source to the destination.
30+
* @param {string[]} properties
31+
* @param {object} source
32+
* @param {object} destination
33+
*/
34+
export function copyPresentProperties(properties, source, destination) {
35+
properties.forEach((property) => {
36+
copyPropertyIfPresent(property, source, destination);
37+
});
38+
}
39+
40+
/**
41+
* Throws an error if `object` has the property `property`.
42+
* @param {object} object
43+
* @param {string} property
44+
* @param {string} errorMessage
45+
*/
46+
export function mustNotHave(object, property, errorMessage) {
47+
if (has(object, property)) {
48+
throw new Error(errorMessage);
49+
}
50+
}

src/utils/theme-settings.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const themeSettingsState = {
2323
export const { baseUrl } = window;
2424

2525
/**
26-
* Method to fetch the theme settings and store in local module state.
26+
* Fetches the theme settings and store in local module state.
2727
* Method is called before Vue boots in `main.js`.
2828
* @return {Promise<{}>}
2929
*/

tests/unit/App.spec.js

+14
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ jest.mock('docc-render/utils/theme-settings', () => ({
2323
getSetting: jest.fn(() => {}),
2424
}));
2525

26+
jest.mock('docc-render/utils/custom-scripts', () => ({
27+
runCustomPageLoadScripts: jest.fn(),
28+
}));
29+
2630
let App;
31+
2732
let fetchThemeSettings = jest.fn();
2833
let getSetting = jest.fn(() => {});
2934

35+
let runCustomPageLoadScripts = jest.fn();
36+
3037
const matchMedia = {
3138
matches: false,
3239
addListener: jest.fn(),
@@ -92,6 +99,7 @@ describe('App', () => {
9299
/* eslint-disable global-require */
93100
App = require('docc-render/App.vue').default;
94101
({ fetchThemeSettings } = require('docc-render/utils/theme-settings'));
102+
({ runCustomPageLoadScripts } = require('docc-render/utils/custom-scripts'));
95103

96104
setThemeSetting({});
97105
window.matchMedia = jest.fn().mockReturnValue(matchMedia);
@@ -244,6 +252,12 @@ describe('App', () => {
244252
expect(wrapper.find(`#${AppTopID}`).exists()).toBe(true);
245253
});
246254

255+
it('does not load "on-load" scripts immediately', () => {
256+
// If "on-load" scripts are run immediately after creating or mounting the app, they will not
257+
// have access to the dynamic documentation HTML for the initial route.
258+
expect(runCustomPageLoadScripts).toHaveBeenCalledTimes(0);
259+
});
260+
247261
describe('Custom CSS Properties', () => {
248262
beforeEach(() => {
249263
setThemeSetting(LightDarkModeCSSSettings);

0 commit comments

Comments
 (0)