Skip to content

Commit 52940d2

Browse files
committed
Add support for custom scripts
1 parent deca402 commit 52940d2

File tree

12 files changed

+476
-1
lines changed

12 files changed

+476
-1
lines changed

src/setup-utils/SwiftDocCRenderRouter.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import Router from 'vue-router';
12+
import AppStore from 'docc-render/stores/AppStore';
1213
import {
1314
notFoundRouteName,
1415
serverErrorRouteName,
@@ -21,6 +22,7 @@ import {
2122
import routes from 'docc-render/routes';
2223
import { baseUrl } from 'docc-render/utils/theme-settings';
2324
import { addPrefixedRoutes } from 'docc-render/utils/route-utils';
25+
import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts';
2426

2527
const defaultRoutes = [
2628
...routes,
@@ -49,6 +51,15 @@ export default function createRouterInstance(routerConfig = {}) {
4951
restoreScrollOnReload();
5052
});
5153

54+
router.afterEach(async () => {
55+
if (AppStore.state.firstRoutingEventHasOccurred) {
56+
await runCustomNavigateScripts();
57+
} else {
58+
await runCustomPageLoadScripts();
59+
AppStore.setFirstRoutingEventHasOccurred(true);
60+
}
61+
});
62+
5263
if (process.env.VUE_APP_TARGET !== 'ide') {
5364
router.onError((error) => {
5465
const { route = { path: '/' } } = error;

src/stores/AppStore.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@ export default {
3030
supportsAutoColorScheme,
3131
systemColorScheme: ColorScheme.light,
3232
availableLocales: [],
33+
firstRoutingEventHasOccurred: false,
3334
},
3435
reset() {
3536
this.state.imageLoadingStrategy = process.env.VUE_APP_TARGET === 'ide'
3637
? ImageLoadingStrategy.eager : ImageLoadingStrategy.lazy;
3738
this.state.preferredColorScheme = Settings.preferredColorScheme || defaultColorScheme;
3839
this.state.supportsAutoColorScheme = supportsAutoColorScheme;
3940
this.state.systemColorScheme = ColorScheme.light;
41+
this.state.firstRoutingEventHasOccurred = false;
4042
},
4143
setImageLoadingStrategy(strategy) {
4244
this.state.imageLoadingStrategy = strategy;
@@ -59,6 +61,9 @@ export default {
5961
setSystemColorScheme(value) {
6062
this.state.systemColorScheme = value;
6163
},
64+
setFirstRoutingEventHasOccurred(hasOccurred) {
65+
this.state.firstRoutingEventHasOccurred = hasOccurred;
66+
},
6267
syncPreferredColorScheme() {
6368
if (!!Settings.preferredColorScheme
6469
&& Settings.preferredColorScheme !== this.state.preferredColorScheme) {

src/utils/custom-scripts.js

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
/** Enum for the allowed values of the `run` property in a custom script. */
21+
const Run = {
22+
onLoad: 'on-load',
23+
onLoadAndNavigate: 'on-load-and-navigate',
24+
onNavigate: 'on-navigate',
25+
};
26+
27+
/**
28+
* Returns whether the custom script should be run when the reader navigates to a subpage.
29+
* @param {object} customScript
30+
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
31+
* "on-load" or "on-load-and-navigate". Also returns true if the `run` property is absent.
32+
*/
33+
function shouldRunOnPageLoad(customScript) {
34+
return !has(customScript, 'run')
35+
|| customScript.run === Run.onLoad || customScript.run === Run.onLoadAndNavigate;
36+
}
37+
38+
/**
39+
* Returns whether the custom script should be run when the reader navigates to a topic.
40+
* @param {object} customScript
41+
* @returns {boolean} Returns whether the custom script has a `run` property with a value of
42+
* "on-navigate" or "on-load-and-navigate".
43+
*/
44+
function shouldRunOnNavigate(customScript) {
45+
return has(customScript, 'run')
46+
&& (customScript.run === Run.onNavigate || customScript.run === Run.onLoadAndNavigate);
47+
}
48+
49+
/**
50+
* Gets the URL for a local custom script given its name.
51+
* @param {string} customScriptName The name of the custom script as spelled in
52+
* custom-scripts.json. While the actual filename (in the custom-scripts directory) is always
53+
* expected to end in ".js", the name in custom-scripts.json may or may not include the ".js"
54+
* extension.
55+
* @returns {string} The absolute URL where the script is, accounting for baseURL.
56+
* @example
57+
* // if baseURL is '/foo'
58+
* urlGivenScriptName('hello-world') // http://localhost:8080/foo/hello-world.js
59+
* urlGivenScriptName('hello-world.js') // http://localhost:8080/foo/hello-world.js
60+
*/
61+
function urlGivenScriptName(customScriptName) {
62+
let scriptNameWithExtension = customScriptName;
63+
64+
// If the provided name does not already include the ".js" extension, add it.
65+
if (customScriptName.slice(-3) !== '.js') {
66+
scriptNameWithExtension = `${customScriptName}.js`;
67+
}
68+
69+
return resolveAbsoluteUrl(['', 'custom-scripts', scriptNameWithExtension]);
70+
}
71+
72+
/**
73+
* Add an HTMLScriptElement containing the custom script to the document's head, which runs the
74+
* script on page load.
75+
* @param {object} customScript The custom script, assuming it should be run on page load.
76+
*/
77+
function addScriptElement(customScript) {
78+
const scriptElement = document.createElement('script');
79+
80+
copyPropertyIfPresent('type', customScript, scriptElement);
81+
82+
if (has(customScript, 'url')) {
83+
mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
84+
mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
85+
86+
scriptElement.src = customScript.url;
87+
88+
// Dynamically-created script elements are `async` by default. But we don't want custom
89+
// scripts to be implicitly async, because if a documentation author adds `defer` to some or
90+
// all of their custom scripts (meaning that they want the execution order of those scripts to
91+
// be deterministic), then the author's `defer` will be overriden by the implicit `async`,
92+
// meaning that the execution order will be unexpectedly nondeterministic.
93+
//
94+
// Therefore, remove the script element's `async` unless async is explicitly enabled.
95+
scriptElement.async = customScript.async || false;
96+
97+
copyPresentProperties(['defer', 'integrity'], customScript, scriptElement);
98+
99+
// If `integrity` is set on an external script, then CORS must be enabled as well.
100+
if (has(customScript, 'integrity')) {
101+
scriptElement.crossOrigin = 'anonymous';
102+
}
103+
} else if (has(customScript, 'name')) {
104+
mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
105+
106+
scriptElement.src = urlGivenScriptName(customScript.name);
107+
scriptElement.async = customScript.async || false;
108+
109+
copyPresentProperties(['async', 'defer', 'integrity'], customScript, scriptElement);
110+
} else if (has(customScript, 'code')) {
111+
mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
112+
mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
113+
mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
114+
115+
scriptElement.innerHTML = customScript.code;
116+
} else {
117+
throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
118+
}
119+
120+
document.head.appendChild(scriptElement);
121+
}
122+
123+
/**
124+
* Run the custom script using `new Function`, which is essentially `eval` but without exposing
125+
* local variables. Useful for running a custom script anytime after page load, namely when the
126+
* reader navigates to a subpage.
127+
* @param {object} customScript The custom script, assuming it should be run on navigate.
128+
*/
129+
async function evalScript(customScript) {
130+
let codeToEval;
131+
132+
if (has(customScript, 'url')) {
133+
mustNotHave(customScript, 'name', 'Custom script cannot have both `url` and `name`.');
134+
mustNotHave(customScript, 'code', 'Custom script cannot have both `url` and `code`.');
135+
136+
if (has(customScript, 'integrity')) {
137+
// External script with integrity. Must also use CORS.
138+
codeToEval = await fetchText(customScript.url, {
139+
integrity: customScript.integrity,
140+
crossOrigin: 'anonymous',
141+
});
142+
} else {
143+
// External script without integrity.
144+
codeToEval = await fetchText(customScript.url);
145+
}
146+
} else if (has(customScript, 'name')) {
147+
mustNotHave(customScript, 'code', 'Custom script cannot have both `name` and `code`.');
148+
149+
const url = urlGivenScriptName(customScript.name);
150+
151+
if (has(customScript, 'integrity')) {
152+
// Local script with integrity. Do not use CORS.
153+
codeToEval = await fetchText(url, { integrity: customScript.integrity });
154+
} else {
155+
// Local script without integrity.
156+
codeToEval = await fetchText(url);
157+
}
158+
} else if (has(customScript, 'code')) {
159+
mustNotHave(customScript, 'async', 'Inline script cannot be `async`.');
160+
mustNotHave(customScript, 'defer', 'Inline script cannot have `defer`.');
161+
mustNotHave(customScript, 'integrity', 'Inline script cannot have `integrity`.');
162+
163+
codeToEval = customScript.code;
164+
} else {
165+
throw new Error('Custom script does not have `url`, `name`, or `code` properties.');
166+
}
167+
168+
// eslint-disable-next-line no-new-func
169+
new Function(codeToEval)();
170+
}
171+
172+
/**
173+
* Run all custom scripts that pass the `predicate` using the `executor`.
174+
* @param {(customScript: object) => boolean} predicate
175+
* @param {(customScript: object) => void} executor
176+
* @returns {Promise<void>}
177+
*/
178+
async function runCustomScripts(predicate, executor) {
179+
const customScriptsFileName = 'custom-scripts.json';
180+
const url = resolveAbsoluteUrl(`/${customScriptsFileName}`);
181+
182+
const response = await fetch(url);
183+
if (!response.ok) {
184+
// If the file is absent, fail silently.
185+
return;
186+
}
187+
188+
const customScripts = await response.json();
189+
if (!Array.isArray(customScripts)) {
190+
throw new Error(`Content of ${customScriptsFileName} should be an array.`);
191+
}
192+
193+
customScripts.filter(predicate).forEach(executor);
194+
}
195+
196+
/**
197+
* Runs all "on-load" and "on-load-and-navigate" scripts.
198+
* @returns {Promise<void>}
199+
*/
200+
export async function runCustomPageLoadScripts() {
201+
await runCustomScripts(shouldRunOnPageLoad, addScriptElement);
202+
}
203+
204+
/**
205+
* Runs all "on-navigate" and "on-load-and-navigate" scripts.
206+
* @returns {Promise<void>}
207+
*/
208+
export async function runCustomNavigateScripts() {
209+
await runCustomScripts(shouldRunOnNavigate, evalScript);
210+
}

src/utils/fetch-text.js

Lines changed: 23 additions & 0 deletions
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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
/** Convenient shorthand for `Object.hasOwn`. */
12+
export const has = Object.hasOwn;
13+
/**
14+
* Copies source.property, if it exists, to destination.property.
15+
* @param {string} property
16+
* @param {object} source
17+
* @param {object} destination
18+
*/
19+
export function copyPropertyIfPresent(property, source, destination) {
20+
if (has(source, property)) {
21+
// eslint-disable-next-line no-param-reassign
22+
destination[property] = source[property];
23+
}
24+
}
25+
26+
/**
27+
* Copies all specified properties present in the source to the destination.
28+
* @param {string[]} properties
29+
* @param {object} source
30+
* @param {object} destination
31+
*/
32+
export function copyPresentProperties(properties, source, destination) {
33+
properties.forEach((property) => {
34+
copyPropertyIfPresent(property, source, destination);
35+
});
36+
}
37+
38+
/**
39+
* Throws an error if `object` has the property `property`.
40+
* @param {object} object
41+
* @param {string} property
42+
* @param {string} errorMessage
43+
*/
44+
export function mustNotHave(object, property, errorMessage) {
45+
if (has(object, property)) {
46+
throw new Error(errorMessage);
47+
}
48+
}

src/utils/theme-settings.js

Lines changed: 1 addition & 1 deletion
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

Lines changed: 14 additions & 0 deletions
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);

tests/unit/components/ContentNode/Reference.spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import { TopicRole } from '@/constants/roles';
2323
const router = createRouterInstance();
2424
const localVue = createLocalVue();
2525
localVue.use(Router);
26+
2627
window.scrollTo = () => ({});
28+
window.fetch = jest.fn().mockResolvedValue({});
2729

2830
describe('Reference', () => {
2931
it('renders a `ReferenceExternal` for external urls', () => {

0 commit comments

Comments
 (0)