diff --git a/app/index.html b/app/index.html
index b185be779..aa93ce018 100644
--- a/app/index.html
+++ b/app/index.html
@@ -17,6 +17,7 @@
<%= process.env.VUE_APP_TITLE %>
+
diff --git a/app/main.js b/app/main.js
index 8036012ea..d5014573e 100644
--- a/app/main.js
+++ b/app/main.js
@@ -8,6 +8,7 @@
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
+import '../webpack-asset-path';
import Vue from 'vue';
import Router from 'vue-router';
import App from '@/App.vue';
diff --git a/bin/baseUrlPlaceholder.js b/bin/baseUrlPlaceholder.js
new file mode 100644
index 000000000..71221503a
--- /dev/null
+++ b/bin/baseUrlPlaceholder.js
@@ -0,0 +1,11 @@
+/**
+ * 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
+*/
+
+module.exports = '{{BASE_PATH}}';
diff --git a/bin/transformIndex.js b/bin/transformIndex.js
new file mode 100644
index 000000000..936b32cdc
--- /dev/null
+++ b/bin/transformIndex.js
@@ -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
+*/
+
+/**
+ * This file is a build-time node script, that replaces all references
+ * of the `BASE_URL_PLACEHOLDER` in the `index.html` file. If it finds references, it stores a
+ * raw copy of the file as `index-template.html`, along with the replaced, ready to serve version
+ * as `index.html`.
+ *
+ * To create a build with a custom base path, just set a `BASE_URL` in your env, and it will be
+ * respected in the build, while still creating an `index-template.html` file.
+ *
+ * This process is part of the docc static-hostable transformation.
+ */
+const fs = require('fs');
+const path = require('path');
+const BASE_URL_PLACEHOLDER = require('./baseUrlPlaceholder');
+
+const indexFile = path.join(__dirname, '../dist/index.html');
+const templateFile = path.resolve(__dirname, '../dist/index-template.html');
+const baseUrl = process.env.BASE_URL || '/';
+
+try {
+ // read the template file
+ const data = fs.readFileSync(indexFile, 'utf8');
+
+ if (!data.includes(BASE_URL_PLACEHOLDER)) {
+ // stop if the placeholder is not found
+ return;
+ }
+
+ // copy it to a new file
+ fs.writeFileSync(templateFile, data, 'utf8');
+
+ // do the replacement
+ const result = data.replace(new RegExp(`${BASE_URL_PLACEHOLDER}/`, 'g'), baseUrl);
+
+ // replace the file
+ fs.writeFileSync(indexFile, result, 'utf8');
+} catch (err) {
+ console.error(err);
+ throw new Error('index.html template processing could not finish.');
+}
diff --git a/package.json b/package.json
index ac56cd47c..dc9ab167c 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
- "build": "vue-cli-service build",
+ "build": "vue-cli-service build && node ./bin/transformIndex.js",
"test": "npm run test:unit && npm run lint && npm run test:license",
"test:license": "./bin/check-source",
"test:unit": "vue-cli-service test:unit",
@@ -17,7 +17,8 @@
"files": [
"src",
"index.js",
- "test-utils.js"
+ "test-utils.js",
+ "webpack-asset-path.js"
],
"dependencies": {
"core-js": "^3.8.2",
diff --git a/src/components/ImageAsset.vue b/src/components/ImageAsset.vue
index ec132f9ed..b8e56854b 100644
--- a/src/components/ImageAsset.vue
+++ b/src/components/ImageAsset.vue
@@ -41,17 +41,18 @@
import imageAsset from 'docc-render/mixins/imageAsset';
import AppStore from 'docc-render/stores/AppStore';
import ColorScheme from 'docc-render/constants/ColorScheme';
+import { normalizeAssetUrl } from 'docc-render/utils/assets';
function constructAttributes(sources) {
if (!sources.length) {
return null;
}
- const srcSet = sources.map(s => `${s.src} ${s.density}`).join(', ');
+ const srcSet = sources.map(s => `${normalizeAssetUrl(s.src)} ${s.density}`).join(', ');
const defaultSource = sources[0];
const attrs = {
srcSet,
- src: defaultSource.src,
+ src: normalizeAssetUrl(defaultSource.src),
};
// All the variants should have the same size, so use the size of the first
diff --git a/src/components/Tutorial/Hero.vue b/src/components/Tutorial/Hero.vue
index 1b23184e4..6104b522e 100644
--- a/src/components/Tutorial/Hero.vue
+++ b/src/components/Tutorial/Hero.vue
@@ -73,6 +73,7 @@ import LinkableElement from 'docc-render/components/LinkableElement.vue';
import GenericModal from 'docc-render/components/GenericModal.vue';
import PlayIcon from 'theme/components/Icons/PlayIcon.vue';
+import { normalizeAssetUrl } from 'docc-render/utils/assets';
import HeroMetadata from './HeroMetadata.vue';
export default {
@@ -139,10 +140,10 @@ export default {
variant.traits.includes('light')
));
- return (lightVariant || {}).url;
+ return lightVariant ? normalizeAssetUrl(lightVariant.url) : '';
},
projectFilesUrl() {
- return this.projectFiles ? this.references[this.projectFiles].url : null;
+ return this.projectFiles ? normalizeAssetUrl(this.references[this.projectFiles].url) : null;
},
bgStyle() {
return {
diff --git a/src/components/VideoAsset.vue b/src/components/VideoAsset.vue
index 1aa5e2c65..47a907983 100644
--- a/src/components/VideoAsset.vue
+++ b/src/components/VideoAsset.vue
@@ -12,7 +12,7 @@
diff --git a/src/setup-utils/SwiftDocCRenderRouter.js b/src/setup-utils/SwiftDocCRenderRouter.js
index 0633495e9..4e4a8ecc8 100644
--- a/src/setup-utils/SwiftDocCRenderRouter.js
+++ b/src/setup-utils/SwiftDocCRenderRouter.js
@@ -9,16 +9,18 @@
*/
import Router from 'vue-router';
-import { saveScrollOnReload, restoreScrollOnReload, scrollBehavior } from 'docc-render/utils/router-utils';
+import {
+ saveScrollOnReload,
+ restoreScrollOnReload,
+ scrollBehavior,
+} from 'docc-render/utils/router-utils';
import routes from 'docc-render/routes';
+import { baseUrl } from 'docc-render/utils/theme-settings';
export default function createRouterInstance(routerConfig = {}) {
const router = new Router({
mode: 'history',
- // This needs to be explicitly set to "/" like this even when the base URL
- // is `/tutorials/`. Otherwise, the router would be mistakenly routing things
- // to redundant paths like `/tutorials/tutorials/...` on the website.
- base: '/',
+ base: baseUrl,
scrollBehavior,
...routerConfig,
routes: routerConfig.routes || routes,
diff --git a/src/utils/__mocks__/theme-settings.js b/src/utils/__mocks__/theme-settings.js
index 72718994d..5e72f56c0 100644
--- a/src/utils/__mocks__/theme-settings.js
+++ b/src/utils/__mocks__/theme-settings.js
@@ -8,6 +8,6 @@
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
+const baseUrl = '';
const getSetting = jest.fn(() => ({}));
-// eslint-disable-next-line import/prefer-default-export
-export { getSetting };
+export { baseUrl, getSetting };
diff --git a/src/utils/assets.js b/src/utils/assets.js
index 91ea88b76..d31d4c0c4 100644
--- a/src/utils/assets.js
+++ b/src/utils/assets.js
@@ -11,6 +11,7 @@
/**
* Utility functions for working with Assets
*/
+import { baseUrl } from 'docc-render/utils/theme-settings';
/**
* Separate array of variants by light/dark mode
@@ -50,3 +51,27 @@ export function extractDensities(variants) {
return list;
}, []);
}
+
+/**
+ * Joins two URL paths, normalizing slashes, so we dont have double slashes.
+ * Does not work with actual URLs.
+ * @param {Array} parts - list of paths to join.
+ * @return {String}
+ */
+export function pathJoin(parts) {
+ const separator = '/';
+ const replace = new RegExp(`${separator}+`, 'g');
+ return parts.join(separator).replace(replace, separator);
+}
+
+/**
+ * Normalizes asset urls, by prefixing the baseUrl path to them.
+ * @param {String} url
+ * @return {String}
+ */
+export function normalizeAssetUrl(url) {
+ if (!url || typeof url !== 'string' || url.startsWith(baseUrl) || !url.startsWith('/')) {
+ return url;
+ }
+ return pathJoin([baseUrl, url]);
+}
diff --git a/src/utils/data.js b/src/utils/data.js
index 3b95b4652..822f6699f 100644
--- a/src/utils/data.js
+++ b/src/utils/data.js
@@ -8,8 +8,10 @@
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
+import { pathJoin } from 'docc-render/utils/assets';
import { queryStringForParams, areEquivalentLocations } from 'docc-render/utils/url-helper';
import emitWarningForSchemaVersionMismatch from 'docc-render/utils/schema-version-check';
+import { baseUrl } from 'docc-render/utils/theme-settings';
export class FetchError extends Error {
constructor(route) {
@@ -39,7 +41,7 @@ export async function fetchData(path, params = {}) {
url.search = queryString;
}
- const response = await fetch(url);
+ const response = await fetch(url.href);
if (isBadResponse(response)) {
throw response;
}
@@ -51,7 +53,7 @@ export async function fetchData(path, params = {}) {
function createDataPath(path) {
const dataPath = path.replace(/\/$/, '');
- return `${process.env.BASE_URL}data${dataPath}.json`;
+ return `${pathJoin([baseUrl, 'data', dataPath])}.json`;
}
export async function fetchDataForRouteEnter(to, from, next) {
diff --git a/src/utils/theme-settings.js b/src/utils/theme-settings.js
index cf9a1014b..6db625709 100644
--- a/src/utils/theme-settings.js
+++ b/src/utils/theme-settings.js
@@ -19,6 +19,7 @@ export const themeSettingsState = {
theme: {},
features: {},
};
+export const { baseUrl } = window;
/**
* Method to fetch the theme settings and store in local module state.
@@ -26,7 +27,7 @@ export const themeSettingsState = {
* @return {Promise<{}>}
*/
export async function fetchThemeSettings() {
- const url = new URL(`${process.env.BASE_URL}theme-settings.json`, window.location.href);
+ const url = new URL(`${baseUrl}theme-settings.json`, window.location.href);
return fetch(url.href)
.then(r => r.json())
.catch(() => ({}));
diff --git a/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js b/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js
index 6f9f53d51..1baa09bd1 100644
--- a/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js
+++ b/tests/unit/setup-utils/SwiftDocCRenderRouter.spec.js
@@ -13,11 +13,17 @@ import Router from 'vue-router';
import SwiftDocCRenderRouter from 'docc-render/setup-utils/SwiftDocCRenderRouter';
import { FetchError } from 'docc-render/utils/data';
+jest.mock('docc-render/utils/theme-settings', () => ({
+ baseUrl: '/',
+}));
+
const mockInstance = {
onError: jest.fn(),
onReady: jest.fn(),
replace: jest.fn(),
+ beforeEach: jest.fn(),
};
+
jest.mock('vue-router', () => jest.fn(() => (mockInstance)));
jest.mock('docc-render/utils/router-utils', () => ({
restoreScrollOnReload: jest.fn(),
diff --git a/tests/unit/utils/assets.spec.js b/tests/unit/utils/assets.spec.js
new file mode 100644
index 000000000..078b98cc0
--- /dev/null
+++ b/tests/unit/utils/assets.spec.js
@@ -0,0 +1,68 @@
+/**
+ * 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 { normalizeAssetUrl, pathJoin } from 'docc-render/utils/assets';
+
+const mockBaseUrl = jest.fn().mockReturnValue('/');
+
+jest.mock('@/utils/theme-settings', () => ({
+ get baseUrl() { return mockBaseUrl(); },
+}));
+
+describe('assets', () => {
+ describe('pathJoin', () => {
+ it.each([
+ [['foo', 'bar'], 'foo/bar'],
+ [['foo/', 'bar'], 'foo/bar'],
+ [['foo', '/bar'], 'foo/bar'],
+ [['foo/', '/bar'], 'foo/bar'],
+ [['foo/', 'bar/'], 'foo/bar/'],
+ [['foo/', '/bar/'], 'foo/bar/'],
+ [['/foo', '/bar'], '/foo/bar'],
+ [['/foo', 'bar/'], '/foo/bar/'],
+ [['/foo/', 'bar/'], '/foo/bar/'],
+ [['/foo/', '/bar/'], '/foo/bar/'],
+ ])('joins params %s into %s', (params, expected) => {
+ expect(pathJoin(params)).toEqual(expected);
+ });
+ });
+ describe('normalizeAssetUrl', () => {
+ it('works correctly if baseurl is just a slash', () => {
+ mockBaseUrl.mockReturnValue('/');
+ expect(normalizeAssetUrl('/foo')).toBe('/foo');
+ });
+
+ it('works when both have slashes leading', () => {
+ mockBaseUrl.mockReturnValue('/base/');
+ expect(normalizeAssetUrl('/foo')).toBe('/base/foo');
+ });
+
+ it('does not change, if passed a url', () => {
+ expect(normalizeAssetUrl('https://foo.com')).toBe('https://foo.com');
+ expect(normalizeAssetUrl('http://foo.com')).toBe('http://foo.com');
+ });
+
+ it('does not change, if path is relative', () => {
+ mockBaseUrl.mockReturnValue('/base');
+ expect(normalizeAssetUrl('foo/bar')).toBe('foo/bar');
+ });
+
+ it('does not change, if the path is already prefixed', () => {
+ mockBaseUrl.mockReturnValue('/base');
+ expect(normalizeAssetUrl('/base/foo')).toBe('/base/foo');
+ });
+
+ it('returns empty, if nothing passed', () => {
+ expect(normalizeAssetUrl('')).toBe('');
+ expect(normalizeAssetUrl(undefined)).toBe(undefined);
+ expect(normalizeAssetUrl(null)).toBe(null);
+ });
+ });
+});
diff --git a/tests/unit/utils/data.spec.js b/tests/unit/utils/data.spec.js
index aee2f1913..e224eb971 100644
--- a/tests/unit/utils/data.spec.js
+++ b/tests/unit/utils/data.spec.js
@@ -19,6 +19,12 @@ import emitWarningForSchemaVersionMismatch from 'docc-render/utils/schema-versio
jest.mock('docc-render/utils/schema-version-check', () => jest.fn());
+const mockBaseUrl = jest.fn().mockReturnValue('/');
+
+jest.mock('docc-render/utils/theme-settings', () => ({
+ get baseUrl() { return mockBaseUrl(); },
+}));
+
const badFetchResponse = {
ok: false,
status: 500,
@@ -112,7 +118,6 @@ describe('fetchData', () => {
});
describe('fetchDataForRouteEnter', () => {
- let originalBaseUrl;
let originalNodeEnv;
const to = {
@@ -123,17 +128,13 @@ describe('fetchDataForRouteEnter', () => {
const next = jest.fn();
beforeEach(() => {
- originalBaseUrl = process.env.BASE_URL;
originalNodeEnv = process.env.NODE_ENV;
-
- process.env.BASE_URL = '/';
process.env.NODE_ENV = 'production';
jest.clearAllMocks();
});
afterEach(() => {
- process.env.BASE_URL = originalBaseUrl;
process.env.NODE_ENV = originalNodeEnv;
});
@@ -144,7 +145,21 @@ describe('fetchDataForRouteEnter', () => {
await expect(window.fetch).toHaveBeenCalledWith(new URL(
'/data/tutorials/augmented-reality/tutorials.json',
window.location.href,
- ));
+ ).href);
+ await expect(data).toEqual(await goodFetchResponse.json());
+
+ window.fetch.mockRestore();
+ });
+
+ it('calls `fetchData` with a configurable base url', async () => {
+ mockBaseUrl.mockReturnValueOnce('/base-prefix/');
+ window.fetch = jest.fn().mockImplementation(() => goodFetchResponse);
+
+ const data = await fetchDataForRouteEnter(to, from, next);
+ await expect(window.fetch).toHaveBeenCalledWith(new URL(
+ '/base-prefix/data/tutorials/augmented-reality/tutorials.json',
+ window.location.href,
+ ).href);
await expect(data).toEqual(await goodFetchResponse.json());
window.fetch.mockRestore();
@@ -199,6 +214,23 @@ describe('fetchDataForRouteEnter', () => {
window.fetch.mockRestore();
}
});
+
+ it('removes trailing slashes from paths', async () => {
+ window.fetch = jest.fn().mockImplementation(() => goodFetchResponse);
+
+ const data = await fetchDataForRouteEnter({
+ name: 'technology-tutorials',
+ path: '/tutorials/augmented-reality/tutorials/',
+ }, from, next);
+
+ await expect(window.fetch).toHaveBeenLastCalledWith(new URL(
+ '/data/tutorials/augmented-reality/tutorials.json',
+ window.location.href,
+ ).href);
+ await expect(data).toEqual(await goodFetchResponse.json());
+
+ window.fetch.mockRestore();
+ });
});
// This is testeed in more detail in `url-helper.spec.js`.
@@ -252,21 +284,17 @@ describe('shouldFetchDataForRouteUpdate', () => {
});
describe('fetchAPIChangesForRoute', () => {
- let originalBaseUrl;
let originalNodeEnv;
beforeEach(() => {
- originalBaseUrl = process.env.BASE_URL;
originalNodeEnv = process.env.NODE_ENV;
- process.env.BASE_URL = '/';
process.env.NODE_ENV = 'production';
jest.clearAllMocks();
});
afterEach(() => {
- process.env.BASE_URL = originalBaseUrl;
process.env.NODE_ENV = originalNodeEnv;
});
diff --git a/tests/unit/utils/theme-settings.spec.js b/tests/unit/utils/theme-settings.spec.js
index e3cf365bf..02b662b9c 100644
--- a/tests/unit/utils/theme-settings.spec.js
+++ b/tests/unit/utils/theme-settings.spec.js
@@ -31,24 +31,25 @@ window.fetch = fetchMock;
describe('theme-settings', () => {
beforeEach(() => {
- process.env.BASE_URL = '/';
importDeps();
jest.clearAllMocks();
});
it('fetches the theme settings from a remote path', async () => {
+ window.baseUrl = '/';
+ importDeps();
await fetchThemeSettings();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('http://localhost/theme-settings.json');
expect(jsonMock).toHaveBeenCalledTimes(1);
});
- it('uses the BASE_URL for the json path', async () => {
- process.env.BASE_URL = '/foo/bar/';
+ it('uses the window.baseUrl for the json path', async () => {
+ window.baseUrl = '/bar/foo/';
importDeps();
await fetchThemeSettings();
expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(fetchMock).toHaveBeenCalledWith('http://localhost/foo/bar/theme-settings.json');
+ expect(fetchMock).toHaveBeenCalledWith('http://localhost/bar/foo/theme-settings.json');
expect(jsonMock).toHaveBeenCalledTimes(1);
});
diff --git a/vue.config.js b/vue.config.js
index 5633843c9..ee19de2f6 100644
--- a/vue.config.js
+++ b/vue.config.js
@@ -10,8 +10,12 @@
const path = require('path');
const vueUtils = require('./src/setup-utils/vue-config-utils');
+const BASE_URL_PLACEHOLDER = require('./bin/baseUrlPlaceholder');
module.exports = vueUtils({
+ // we are setting a hard public path to the placeholder template.
+ // after the build is done, we will replace this with the BASE_URL env the user specified.
+ publicPath: process.env.NODE_ENV === 'development' ? undefined : BASE_URL_PLACEHOLDER,
pages: {
index: {
entry: 'app/main.js',
diff --git a/webpack-asset-path.js b/webpack-asset-path.js
new file mode 100644
index 000000000..3475fcf6f
--- /dev/null
+++ b/webpack-asset-path.js
@@ -0,0 +1,14 @@
+/**
+ * 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
+*/
+// The variable below is a Webpack magic var, that sets the asset public path dynamically.
+// See https://webpack.js.org/guides/public-path/#on-the-fly
+
+/* eslint-disable */
+__webpack_public_path__ = window.baseUrl;