Skip to content

Commit a5ced7f

Browse files
committed
Add support for custom scripts
1 parent c781d37 commit a5ced7f

File tree

12 files changed

+472
-2
lines changed

12 files changed

+472
-2
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
saveScrollOnReload,
1415
restoreScrollOnReload,
@@ -17,6 +18,7 @@ import {
1718
import routes, { fallbackRoutes } from 'docc-render/routes';
1819
import { baseUrl } from 'docc-render/utils/theme-settings';
1920
import { addPrefixedRoutes } from 'docc-render/utils/route-utils';
21+
import { runCustomPageLoadScripts, runCustomNavigateScripts } from 'docc-render/utils/custom-scripts';
2022

2123
const defaultRoutes = [
2224
...addPrefixedRoutes(routes),
@@ -42,6 +44,15 @@ export default function createRouterInstance(routerConfig = {}) {
4244
restoreScrollOnReload();
4345
});
4446

47+
router.afterEach(async () => {
48+
if (AppStore.state.firstRoutingEventHasOccurred) {
49+
await runCustomNavigateScripts();
50+
} else {
51+
await runCustomPageLoadScripts();
52+
AppStore.setFirstRoutingEventHasOccurred(true);
53+
}
54+
});
55+
4556
if (process.env.VUE_APP_TARGET !== 'ide') {
4657
router.onError((error) => {
4758
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 {
12+
copyPresentProperties,
13+
copyPropertyIfPresent,
14+
has,
15+
mustNotHave,
16+
} from 'docc-render/utils/object-properties';
17+
import { resolveAbsoluteUrl } from 'docc-render/utils/url-helper';
18+
import { fetchText } from 'docc-render/utils/data';
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/data.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import emitWarningForSchemaVersionMismatch from 'docc-render/utils/schema-versio
1616
import RedirectError from 'docc-render/errors/RedirectError';
1717
import FetchError from 'docc-render/errors/FetchError';
1818

19-
export async function fetchData(path, params = {}, options = {}) {
19+
async function safeFetch(path, params = {}, options = {}) {
2020
function isBadResponse(response) {
2121
// When this is running in an IDE target, the `fetch` API will be used with
2222
// custom URL schemes. Right now, WebKit will return successful responses
@@ -50,11 +50,35 @@ export async function fetchData(path, params = {}, options = {}) {
5050
});
5151
}
5252

53+
return response;
54+
}
55+
56+
/**
57+
* Fetch the contents of a file as an object.
58+
* @param {string} path The file path.
59+
* @param {any} params Object containing URL query parameters.
60+
* @param {RequestInit} options Fetch options.
61+
* @returns {Promise<any>} The contents of the file.
62+
*/
63+
export async function fetchData(path, params = {}, options = {}) {
64+
const response = await safeFetch(path, params, options);
5365
const json = await response.json();
5466
emitWarningForSchemaVersionMismatch(json.schemaVersion);
5567
return json;
5668
}
5769

70+
/**
71+
* Fetch the contents of a file as text.
72+
* @param {string} path The file path.
73+
* @param {any} params Object containing URL query parameters.
74+
* @param {RequestInit} options Fetch options.
75+
* @returns {Promise<string>} The text contents of the file.
76+
*/
77+
export async function fetchText(path, params = {}, options = {}) {
78+
const response = await safeFetch(path, params, options);
79+
return response.text();
80+
}
81+
5882
function createDataPath(path) {
5983
const dataPath = path.replace(/\/$/, '');
6084
return `${normalizePath(['/data', dataPath])}.json`;

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
*/

0 commit comments

Comments
 (0)