From 0e0e493f8d8e029472502af77d29529a2e374125 Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Thu, 18 Nov 2021 19:23:45 +0200 Subject: [PATCH] feat: add static-hostable support, by allowing changing the baseUrl at runtime --- app/index.html | 1 + app/main.js | 1 + bin/baseUrlPlaceholder.js | 11 +++ bin/transformIndex.js | 50 ++++++++++++++ package.json | 5 +- src/components/ImageAsset.vue | 5 +- src/components/Tutorial/Hero.vue | 5 +- src/components/VideoAsset.vue | 9 ++- src/setup-utils/SwiftDocCRenderRouter.js | 12 ++-- src/utils/__mocks__/theme-settings.js | 4 +- src/utils/assets.js | 25 +++++++ src/utils/data.js | 6 +- src/utils/theme-settings.js | 3 +- .../setup-utils/SwiftDocCRenderRouter.spec.js | 6 ++ tests/unit/utils/assets.spec.js | 68 +++++++++++++++++++ tests/unit/utils/data.spec.js | 48 ++++++++++--- tests/unit/utils/theme-settings.spec.js | 9 +-- vue.config.js | 4 ++ webpack-asset-path.js | 14 ++++ 19 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 bin/baseUrlPlaceholder.js create mode 100644 bin/transformIndex.js create mode 100644 tests/unit/utils/assets.spec.js create mode 100644 webpack-asset-path.js 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 47314e79e..193ce4045 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", @@ -15,7 +15,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;