diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap new file mode 100644 index 0000000000..e7f0296d38 --- /dev/null +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collectRedirects should throw if plugin option redirects contain invalid to paths 1`] = ` +"You are trying to create client-side redirections to paths that do not exist: +- /this/path/does/not/exist2 +- /this/path/does/not/exist2 + +Valid paths you can redirect to: +- / +- /someExistingPath +- /anotherExistingPath +" +`; + +exports[`collectRedirects should throw if redirect creator creates invalid redirects 1`] = ` +"Some created redirects are invalid: +- {\\"fromRoutePath\\":\\"https://google.com/\\",\\"toRoutePath\\":\\"/\\"} => Validation error: fromRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string +- {\\"fromRoutePath\\":\\"//abc\\",\\"toRoutePath\\":\\"/\\"} => Validation error: fromRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string +- {\\"fromRoutePath\\":\\"/def?queryString=toto\\",\\"toRoutePath\\":\\"/\\"} => Validation error: fromRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string +" +`; diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap index 5c4ea33911..3b129aee9e 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap @@ -1,16 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateRedirect throw for bad redirects 1`] = ` -"fromRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string -Redirect={\\"fromRoutePath\\":\\"https://fb.com/fromSomePath\\",\\"toRoutePath\\":\\"/toSomePath\\"}" -`; +exports[`validateRedirect throw for bad redirects 1`] = `"{\\"fromRoutePath\\":\\"https://fb.com/fromSomePath\\",\\"toRoutePath\\":\\"/toSomePath\\"} => Validation error: fromRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string"`; -exports[`validateRedirect throw for bad redirects 2`] = ` -"toRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string -Redirect={\\"fromRoutePath\\":\\"/fromSomePath\\",\\"toRoutePath\\":\\"https://fb.com/toSomePath\\"}" -`; +exports[`validateRedirect throw for bad redirects 2`] = `"{\\"fromRoutePath\\":\\"/fromSomePath\\",\\"toRoutePath\\":\\"https://fb.com/toSomePath\\"} => Validation error: toRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string"`; -exports[`validateRedirect throw for bad redirects 3`] = ` -"toRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string -Redirect={\\"fromRoutePath\\":\\"/fromSomePath\\",\\"toRoutePath\\":\\"/toSomePath?queryString=xyz\\"}" -`; +exports[`validateRedirect throw for bad redirects 3`] = `"{\\"fromRoutePath\\":\\"/fromSomePath\\",\\"toRoutePath\\":\\"/toSomePath?queryString=xyz\\"} => Validation error: toRoutePath is not a valid pathname. Pathname should start with / and not contain any domain or query string"`; diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts index 38cec1380d..865ae37ae9 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts @@ -73,6 +73,67 @@ describe('collectRedirects', () => { ]); }); + test('should collect redirects from plugin option redirects', () => { + expect( + collectRedirects( + createTestPluginContext( + { + redirects: [ + { + from: '/someLegacyPath', + to: '/somePath', + }, + { + from: ['/someLegacyPathArray1', '/someLegacyPathArray2'], + to: '/', + }, + ], + }, + ['/', '/somePath'], + ), + ), + ).toEqual([ + { + fromRoutePath: '/someLegacyPath', + toRoutePath: '/somePath', + }, + { + fromRoutePath: '/someLegacyPathArray1', + toRoutePath: '/', + }, + { + fromRoutePath: '/someLegacyPathArray2', + toRoutePath: '/', + }, + ]); + }); + + test('should throw if plugin option redirects contain invalid to paths', () => { + expect(() => + collectRedirects( + createTestPluginContext( + { + redirects: [ + { + from: '/someLegacyPath', + to: '/', + }, + { + from: '/someLegacyPath', + to: '/this/path/does/not/exist2', + }, + { + from: '/someLegacyPath', + to: '/this/path/does/not/exist2', + }, + ], + }, + ['/', '/someExistingPath', '/anotherExistingPath'], + ), + ), + ).toThrowErrorMatchingSnapshot(); + }); + test('should collect redirects with custom redirect creator', () => { expect( collectRedirects( @@ -118,6 +179,28 @@ describe('collectRedirects', () => { ]); }); + test('should throw if redirect creator creates invalid redirects', () => { + expect(() => + collectRedirects( + createTestPluginContext( + { + createRedirects: (routePath) => { + if (routePath === '/') { + return [ + `https://google.com/`, + `//abc`, + `/def?queryString=toto`, + ]; + } + return; + }, + }, + ['/'], + ), + ), + ).toThrowErrorMatchingSnapshot(); + }); + test('should filter unwanted redirects', () => { expect( collectRedirects( diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts index af0bf718c2..9b13ca2aea 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts @@ -36,11 +36,13 @@ describe('normalizePluginOptions', () => { fromExtensions: ['exe', 'zip'], toExtensions: ['html'], createRedirects, + redirects: [{from: '/x', to: '/y'}], }), ).toEqual({ fromExtensions: ['exe', 'zip'], toExtensions: ['html'], createRedirects, + redirects: [{from: '/x', to: '/y'}], }); }); diff --git a/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts index fee4ba58ec..cc354c5be9 100644 --- a/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts +++ b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts @@ -5,25 +5,67 @@ * LICENSE file in the root directory of this source tree. */ -import {flatten, uniqBy} from 'lodash'; +import {flatten, uniqBy, difference} from 'lodash'; import { RedirectsCreator, PluginContext, RedirectMetadata, PluginOptions, + RedirectOption, } from './types'; import { fromExtensionsRedirectCreator, toExtensionsRedirectCreator, } from './redirectCreators'; +import {validateRedirect} from './redirectValidation'; export default function collectRedirects( pluginContext: PluginContext, ): RedirectMetadata[] { const redirects = doCollectRedirects(pluginContext); + validateCollectedRedirects(redirects, pluginContext); return filterUnwantedRedirects(redirects, pluginContext); } +function validateCollectedRedirects( + redirects: RedirectMetadata[], + pluginContext: PluginContext, +) { + const redirectValidationErrors: string[] = redirects + .map((redirect) => { + try { + validateRedirect(redirect); + } catch (e) { + return e.message; + } + }) + .filter(Boolean); + if (redirectValidationErrors.length > 0) { + throw new Error( + `Some created redirects are invalid: +- ${redirectValidationErrors.join('\n- ')} +`, + ); + } + + const allowedToPaths = pluginContext.routesPaths; + + const toPaths = redirects.map((redirect) => redirect.toRoutePath); + + const illegalToPaths = difference(toPaths, allowedToPaths); + + if (illegalToPaths.length > 0) { + throw new Error( + `You are trying to create client-side redirections to paths that do not exist: +- ${illegalToPaths.join('\n- ')} + +Valid paths you can redirect to: +- ${allowedToPaths.join('\n- ')} +`, + ); + } +} + function filterUnwantedRedirects( redirects: RedirectMetadata[], pluginContext: PluginContext, @@ -46,20 +88,42 @@ function doCollectRedirects(pluginContext: PluginContext): RedirectMetadata[] { pluginContext.options, ); - return flatten( + const optionsRedirects = collectPluginOptionRedirects(pluginContext); + + const redirectCreatorsRedirects = flatten( redirectsCreators.map((redirectCreator) => { return createRoutesPathsRedirects(redirectCreator, pluginContext); }), ); + + return [...optionsRedirects, ...redirectCreatorsRedirects]; +} + +function collectPluginOptionRedirects( + pluginContext: PluginContext, +): RedirectMetadata[] { + // For conveniency, user can use a string or a string[] + function optionToRedirects(option: RedirectOption): RedirectMetadata[] { + if (typeof option.from === 'string') { + return [{fromRoutePath: option.from, toRoutePath: option.to}]; + } else { + return option.from.map((fromRoutePath) => ({ + fromRoutePath, + toRoutePath: option.to, + })); + } + } + + return flatten(pluginContext.options.redirects.map(optionToRedirects)); } function buildRedirectCreators(options: PluginOptions): RedirectsCreator[] { - const noopRedirectCreator: RedirectsCreator = (_routePath: string) => []; - return [ + const redirectCreators = [ fromExtensionsRedirectCreator(options.fromExtensions), toExtensionsRedirectCreator(options.toExtensions), - options.createRedirects ?? noopRedirectCreator, ]; + options.createRedirects && redirectCreators.push(options.createRedirects); + return redirectCreators; } // Create all redirects for a list of route path diff --git a/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts b/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts index a9ae565959..cce92f171d 100644 --- a/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts +++ b/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts @@ -5,12 +5,19 @@ * LICENSE file in the root directory of this source tree. */ -import {PluginOptions, RedirectsCreator, UserPluginOptions} from './types'; +import { + PluginOptions, + RedirectOption, + RedirectsCreator, + UserPluginOptions, +} from './types'; import * as Yup from 'yup'; +import {PathnameValidator} from './redirectValidation'; export const DefaultPluginOptions: PluginOptions = { fromExtensions: [], toExtensions: [], + redirects: [], }; function isRedirectsCreator(value: any): value is RedirectsCreator | undefined { @@ -20,9 +27,28 @@ function isRedirectsCreator(value: any): value is RedirectsCreator | undefined { return value instanceof Function; } +const RedirectPluginOptionValidation = Yup.object({ + to: PathnameValidator.required(), + // wasn't able to use .when("from")...had cyclic dependency error + // (https://stackoverflow.com/a/56866941/82609) + from: Yup.mixed().test({ + name: 'from', + message: '${path} contains invalid redirection value', + test: (from) => { + return Array.isArray(from) + ? Yup.array() + .of(PathnameValidator.required()) + .required() + .isValidSync(from) + : PathnameValidator.required().isValidSync(from); + }, + }), +}); + const UserOptionsSchema = Yup.object().shape({ fromExtensions: Yup.array().of(Yup.string().required().min(0)), toExtensions: Yup.array().of(Yup.string().required().min(0)), + redirects: Yup.array().of(RedirectPluginOptionValidation) as any, // TODO Yup expect weird typing here createRedirects: Yup.mixed().test( 'createRedirects', 'createRedirects should be a function', diff --git a/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts index 7c732d0375..b02d44a306 100644 --- a/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts +++ b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts @@ -16,9 +16,11 @@ const validPathnameTest: Yup.TestOptions = { test: isValidPathname, }; +export const PathnameValidator = Yup.string().test(validPathnameTest); + const RedirectSchema = Yup.object({ - fromRoutePath: Yup.string().required().test(validPathnameTest), - toRoutePath: Yup.string().required().test(validPathnameTest), + fromRoutePath: PathnameValidator.required(), + toRoutePath: PathnameValidator.required(), }); export function validateRedirect(redirect: RedirectMetadata) { @@ -26,6 +28,8 @@ export function validateRedirect(redirect: RedirectMetadata) { RedirectSchema.validateSync(redirect); } catch (e) { // Tells the user which redirect is the problem! - throw new Error(`${e.message}\nRedirect=${JSON.stringify(redirect)}`); + throw new Error( + `${JSON.stringify(redirect)} => Validation error: ${e.message}`, + ); } } diff --git a/packages/docusaurus-plugin-client-redirects/src/types.ts b/packages/docusaurus-plugin-client-redirects/src/types.ts index e9a208074b..55263cc04c 100644 --- a/packages/docusaurus-plugin-client-redirects/src/types.ts +++ b/packages/docusaurus-plugin-client-redirects/src/types.ts @@ -10,9 +10,15 @@ import {Props} from '@docusaurus/types'; export type PluginOptions = { fromExtensions: string[]; toExtensions: string[]; + redirects: RedirectOption[]; createRedirects?: RedirectsCreator; }; +export type RedirectOption = { + to: string; + from: string | string[]; +}; + export type UserPluginOptions = Partial; // The minimal infos the plugin needs to work