From 5042b62643342469fdcc9e09c85489389e656da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Fri, 21 Jul 2023 19:54:40 +0200 Subject: [PATCH] feat(client-redirects-plugin): support fully qualified urls and querystring/hash in destination/to url (#9171) --- .../collectRedirects.test.ts.snap | 8 +- .../redirectValidation.test.ts.snap | 10 +-- .../writeRedirectFiles.test.ts.snap | 38 +++++++++ .../src/__tests__/collectRedirects.test.ts | 83 ++++++++++++++++++- .../src/__tests__/redirectValidation.test.ts | 34 ++++---- .../src/__tests__/writeRedirectFiles.test.ts | 43 +++++++++- .../src/collectRedirects.ts | 22 ++++- .../src/createRedirectPageContent.ts | 18 +++- .../src/index.ts | 2 + .../src/options.ts | 2 +- .../src/redirectValidation.ts | 2 +- .../templates/redirectPage.template.html.ts | 2 +- .../src/writeRedirectFiles.ts | 5 +- .../validationSchemas.test.ts.snap | 4 +- .../src/validationSchemas.ts | 2 +- website/_dogfooding/dogfooding.config.js | 27 ++++++ website/docusaurus.config.js | 2 + 17 files changed, 260 insertions(+), 44 deletions(-) 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 index 25887592b6a4e..52c77a2809e19 100644 --- 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 @@ -5,6 +5,8 @@ exports[`collectRedirects throw if plugin option redirects contain invalid to pa These paths are redirected to but do not exist: - /this/path/does/not/exist2 +- /this/path/does/not/exist3 +- /this/path/does/not/exist4 Valid paths you can redirect to: - / @@ -37,8 +39,8 @@ exports[`collectRedirects throws if redirect creator creates array of array redi exports[`collectRedirects throws if redirect creator creates invalid redirects 1`] = ` "Some created redirects are invalid: -- {"from":"https://google.com/","to":"/"} => Validation error: "from" is not a valid pathname. Pathname should start with slash and not contain any domain or query string. -- {"from":"//abc","to":"/"} => Validation error: "from" is not a valid pathname. Pathname should start with slash and not contain any domain or query string. -- {"from":"/def?queryString=toto","to":"/"} => Validation error: "from" is not a valid pathname. Pathname should start with slash and not contain any domain or query string. +- {"from":"https://google.com/","to":"/"} => Validation error: "from" (https://google.com/) is not a valid pathname. Pathname should start with slash and not contain any domain or query string. +- {"from":"//abc","to":"/"} => Validation error: "from" (//abc) is not a valid pathname. Pathname should start with slash and not contain any domain or query string. +- {"from":"/def?queryString=toto","to":"/"} => Validation error: "from" (/def?queryString=toto) is not a valid pathname. Pathname should start with slash 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 d76b4d18d1f31..98b8cc25eee5d 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,11 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateRedirect throw for bad redirects 1`] = `"{"from":"https://fb.com/fromSomePath","to":"/toSomePath"} => Validation error: "from" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; +exports[`validateRedirect throw for bad redirects 1`] = `"{"from":"https://fb.com/fromSomePath","to":"/toSomePath"} => Validation error: "from" (https://fb.com/fromSomePath) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; -exports[`validateRedirect throw for bad redirects 2`] = `"{"from":"/fromSomePath","to":"https://fb.com/toSomePath"} => Validation error: "to" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; +exports[`validateRedirect throw for bad redirects 2`] = `"{"from":"/fromSomePath?a=1","to":"/toSomePath"} => Validation error: "from" (/fromSomePath?a=1) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; -exports[`validateRedirect throw for bad redirects 3`] = `"{"from":"/fromSomePath","to":"/toSomePath?queryString=xyz"} => Validation error: "to" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; - -exports[`validateRedirect throw for bad redirects 4`] = `"{"from":null,"to":"/toSomePath?queryString=xyz"} => Validation error: "from" must be a string"`; - -exports[`validateRedirect throw for bad redirects 5`] = `"{"from":["hey"],"to":"/toSomePath?queryString=xyz"} => Validation error: "from" must be a string"`; +exports[`validateRedirect throw for bad redirects 3`] = `"{"from":"/fromSomePath#anchor","to":"/toSomePath"} => Validation error: "from" (/fromSomePath#anchor) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap index 3c53d36e7c1a9..603122017c1ec 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap @@ -1,5 +1,43 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`toRedirectFiles creates appropriate metadata absolute url: fileContent 1`] = ` +[ + " + + + + + + + +", + " + + + + + + + +", + " + + + + + + + +", +] +`; + exports[`toRedirectFiles creates appropriate metadata for empty baseUrl: fileContent baseUrl=empty 1`] = ` [ " 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 021eba2603870..e303f7289b922 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts @@ -95,13 +95,51 @@ describe('collectRedirects', () => { from: '/someLegacyPath', to: '/somePath', }, + { + from: '/someLegacyPath2', + to: '/some Path2', + }, + { + from: '/someLegacyPath3', + to: '/some%20Path3', + }, { from: ['/someLegacyPathArray1', '/someLegacyPathArray2'], to: '/', }, + + { + from: '/localQS', + to: '/somePath?a=1&b=2', + }, + { + from: '/localAnchor', + to: '/somePath#anchor', + }, + { + from: '/localQSAnchor', + to: '/somePath?a=1&b=2#anchor', + }, + + { + from: '/absolute', + to: 'https://docusaurus.io/somePath', + }, + { + from: '/absoluteQS', + to: 'https://docusaurus.io/somePath?a=1&b=2', + }, + { + from: '/absoluteAnchor', + to: 'https://docusaurus.io/somePath#anchor', + }, + { + from: '/absoluteQSAnchor', + to: 'https://docusaurus.io/somePath?a=1&b=2#anchor', + }, ], }, - ['/', '/somePath'], + ['/', '/somePath', '/some%20Path2', '/some Path3'], ), undefined, ), @@ -110,6 +148,14 @@ describe('collectRedirects', () => { from: '/someLegacyPath', to: '/somePath', }, + { + from: '/someLegacyPath2', + to: '/some Path2', + }, + { + from: '/someLegacyPath3', + to: '/some%20Path3', + }, { from: '/someLegacyPathArray1', to: '/', @@ -118,6 +164,35 @@ describe('collectRedirects', () => { from: '/someLegacyPathArray2', to: '/', }, + { + from: '/localQS', + to: '/somePath?a=1&b=2', + }, + { + from: '/localAnchor', + to: '/somePath#anchor', + }, + { + from: '/localQSAnchor', + to: '/somePath?a=1&b=2#anchor', + }, + + { + from: '/absolute', + to: 'https://docusaurus.io/somePath', + }, + { + from: '/absoluteQS', + to: 'https://docusaurus.io/somePath?a=1&b=2', + }, + { + from: '/absoluteAnchor', + to: 'https://docusaurus.io/somePath#anchor', + }, + { + from: '/absoluteQSAnchor', + to: 'https://docusaurus.io/somePath?a=1&b=2#anchor', + }, ]); }); @@ -209,7 +284,11 @@ describe('collectRedirects', () => { }, { from: '/someLegacyPath', - to: '/this/path/does/not/exist2', + to: '/this/path/does/not/exist3', + }, + { + from: '/someLegacyPath', + to: '/this/path/does/not/exist4?a=b#anchor', }, ], }, diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts index 2d357985ba044..935378c09968e 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts @@ -26,6 +26,18 @@ describe('validateRedirect', () => { from: '/fromSomePath', to: '/to/Some/Path', }); + validateRedirect({ + from: '/fromSomePath', + to: '/toSomePath?a=1', + }); + validateRedirect({ + from: '/fromSomePath', + to: '/toSomePath#anchor', + }); + validateRedirect({ + from: '/fromSomePath', + to: '/toSomePath?a=1&b=2#anchor', + }); }).not.toThrow(); }); @@ -39,29 +51,15 @@ describe('validateRedirect', () => { expect(() => validateRedirect({ - from: '/fromSomePath', - to: 'https://fb.com/toSomePath', - }), - ).toThrowErrorMatchingSnapshot(); - - expect(() => - validateRedirect({ - from: '/fromSomePath', - to: '/toSomePath?queryString=xyz', - }), - ).toThrowErrorMatchingSnapshot(); - - expect(() => - validateRedirect({ - from: null as unknown as string, - to: '/toSomePath?queryString=xyz', + from: '/fromSomePath?a=1', + to: '/toSomePath', }), ).toThrowErrorMatchingSnapshot(); expect(() => validateRedirect({ - from: ['hey'] as unknown as string, - to: '/toSomePath?queryString=xyz', + from: '/fromSomePath#anchor', + to: '/toSomePath', }), ).toThrowErrorMatchingSnapshot(); }); diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts index f6cf9476f92f9..aef8b6013573a 100644 --- a/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts +++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts @@ -24,8 +24,12 @@ describe('createToUrl', () => { expect(createToUrl('/', '/docs/something/else/')).toBe( '/docs/something/else/', ); - expect(createToUrl('/', 'docs/something/else')).toBe( - '/docs/something/else', + expect(createToUrl('/', 'docs/something/else')).toBe('docs/something/else'); + expect(createToUrl('/', './docs/something/else')).toBe( + './docs/something/else', + ); + expect(createToUrl('/', 'https://docs/something/else')).toBe( + 'https://docs/something/else', ); }); @@ -37,12 +41,45 @@ describe('createToUrl', () => { '/baseUrl/docs/something/else/', ); expect(createToUrl('/baseUrl/', 'docs/something/else')).toBe( - '/baseUrl/docs/something/else', + 'docs/something/else', + ); + expect(createToUrl('/baseUrl/', './docs/something/else')).toBe( + './docs/something/else', + ); + expect(createToUrl('/baseUrl/', 'https://docs/something/else')).toBe( + 'https://docs/something/else', ); }); }); describe('toRedirectFiles', () => { + it('creates appropriate metadata absolute url', () => { + const pluginContext = { + outDir: '/tmp/someFixedOutDir', + baseUrl: '/', + }; + + const redirectFiles = toRedirectFiles( + [ + {from: '/abc', to: 'https://docusaurus.io/'}, + {from: '/def', to: 'https://docusaurus.io/docs/intro?a=1'}, + {from: '/ijk', to: 'https://docusaurus.io/docs/intro#anchor'}, + ], + pluginContext, + undefined, + ); + + expect(redirectFiles.map((f) => f.fileAbsolutePath)).toEqual([ + path.join(pluginContext.outDir, '/abc/index.html'), + path.join(pluginContext.outDir, '/def/index.html'), + path.join(pluginContext.outDir, '/ijk/index.html'), + ]); + + expect(redirectFiles.map((f) => f.fileContent)).toMatchSnapshot( + 'fileContent', + ); + }); + it('creates appropriate metadata trailingSlash=undefined', () => { const pluginContext = { outDir: '/tmp/someFixedOutDir', diff --git a/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts index 666c51e433ab6..92a29a6ed307a 100644 --- a/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts +++ b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts @@ -79,8 +79,25 @@ function validateCollectedRedirects( ); } - const allowedToPaths = pluginContext.relativeRoutesPaths; - const toPaths = redirects.map((redirect) => redirect.to); + const allowedToPaths = pluginContext.relativeRoutesPaths.map((p) => + decodeURI(p), + ); + const toPaths = redirects + .map((redirect) => redirect.to) + // We now allow "to" to contain any string + // We only do this "broken redirect" check from to that looks like pathnames + // note: we allow querystring/anchors + // See https://github.com/facebook/docusaurus/issues/6845 + .map((to) => { + if (to.startsWith('/')) { + try { + return decodeURI(new URL(to, 'https://example.com').pathname); + } catch (e) {} + } + return undefined; + }) + .filter((to): to is string => typeof to !== 'undefined'); + const trailingSlashConfig = pluginContext.siteConfig.trailingSlash; // Key is the path, value is whether a valid toPath with a different trailing // slash exists; if the key doesn't exist it means it's valid @@ -103,7 +120,6 @@ function validateCollectedRedirects( } }); if (differByTrailSlash.size > 0) { - console.log(differByTrailSlash); const errors = Array.from(differByTrailSlash.entries()); let message = diff --git a/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts b/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts index b5d0a21141ff6..3cdf1b7f6e144 100644 --- a/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts +++ b/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts @@ -13,11 +13,26 @@ const getCompiledRedirectPageTemplate = _.memoize(() => eta.compile(redirectPageTemplate.trim()), ); -function renderRedirectPageTemplate(data: {toUrl: string}) { +function renderRedirectPageTemplate(data: { + toUrl: string; + searchAnchorForwarding: boolean; +}) { const compiled = getCompiledRedirectPageTemplate(); return compiled(data, eta.defaultConfig); } +// if the target url does not include ?search#anchor, +// we forward search/anchor that the redirect page receives +function searchAnchorForwarding(toUrl: string): boolean { + try { + const url = new URL(toUrl, 'https://example.com'); + const containsSearchOrAnchor = url.search || url.hash; + return !containsSearchOrAnchor; + } catch (e) { + return false; + } +} + export default function createRedirectPageContent({ toUrl, }: { @@ -25,5 +40,6 @@ export default function createRedirectPageContent({ }): string { return renderRedirectPageTemplate({ toUrl: encodeURI(toUrl), + searchAnchorForwarding: searchAnchorForwarding(toUrl), }); } diff --git a/packages/docusaurus-plugin-client-redirects/src/index.ts b/packages/docusaurus-plugin-client-redirects/src/index.ts index 5b64560bac045..991f739efe0f0 100644 --- a/packages/docusaurus-plugin-client-redirects/src/index.ts +++ b/packages/docusaurus-plugin-client-redirects/src/index.ts @@ -34,6 +34,8 @@ export default function pluginClientRedirectsPages( siteConfig: props.siteConfig, }; + console.log({propsBaseUrl: props.baseUrl}); + const redirects: RedirectItem[] = collectRedirects( pluginContext, trailingSlash, diff --git a/packages/docusaurus-plugin-client-redirects/src/options.ts b/packages/docusaurus-plugin-client-redirects/src/options.ts index 675e38902898c..0e58ad8038aa2 100644 --- a/packages/docusaurus-plugin-client-redirects/src/options.ts +++ b/packages/docusaurus-plugin-client-redirects/src/options.ts @@ -45,11 +45,11 @@ export const DEFAULT_OPTIONS: Partial = { }; const RedirectPluginOptionValidation = Joi.object({ - to: PathnameSchema.required(), from: Joi.alternatives().try( PathnameSchema.required(), Joi.array().items(PathnameSchema.required()), ), + to: Joi.string().required(), }); const isString = Joi.string().required().not(null); diff --git a/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts index 7b7a471f4b6f9..7a6db17f6aa6d 100644 --- a/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts +++ b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts @@ -10,7 +10,7 @@ import type {RedirectItem} from './types'; const RedirectSchema = Joi.object({ from: PathnameSchema.required(), - to: PathnameSchema.required(), + to: Joi.string().required(), }); export function validateRedirect(redirect: RedirectItem): void { diff --git a/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts b/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts index beb4579fbf79b..2030a3de04e58 100644 --- a/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts +++ b/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts @@ -14,7 +14,7 @@ export default ` `; diff --git a/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts b/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts index 4f70e064ce024..e1cf88a86e55d 100644 --- a/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts +++ b/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts @@ -23,7 +23,10 @@ export type RedirectFile = { }; export function createToUrl(baseUrl: string, to: string): string { - return normalizeUrl([baseUrl, to]); + if (to.startsWith('/')) { + return normalizeUrl([baseUrl, to]); + } + return to; } // Create redirect file path diff --git a/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap b/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap index b1f8cc75ee7eb..4e75d01e01f5d 100644 --- a/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap +++ b/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap @@ -30,9 +30,9 @@ exports[`validation schemas contentVisibilitySchema: for value={"unlisted":"bad exports[`validation schemas contentVisibilitySchema: for value={"unlisted":42} 1`] = `""unlisted" must be a boolean"`; -exports[`validation schemas pathnameSchema: for value="foo" 1`] = `""value" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; +exports[`validation schemas pathnameSchema: for value="foo" 1`] = `""value" (foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; -exports[`validation schemas pathnameSchema: for value="https://github.com/foo" 1`] = `""value" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; +exports[`validation schemas pathnameSchema: for value="https://github.com/foo" 1`] = `""value" (https://github.com/foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; exports[`validation schemas pluginIdSchema: for value="/docs" 1`] = `"Illegal plugin ID value "/docs": it should only contain alphanumerics, underscores, and dashes."`; diff --git a/packages/docusaurus-utils-validation/src/validationSchemas.ts b/packages/docusaurus-utils-validation/src/validationSchemas.ts index 5d2959359c814..a0a8c72f16b73 100644 --- a/packages/docusaurus-utils-validation/src/validationSchemas.ts +++ b/packages/docusaurus-utils-validation/src/validationSchemas.ts @@ -91,7 +91,7 @@ export const PathnameSchema = Joi.string() return val; }) .message( - '{{#label}} is not a valid pathname. Pathname should start with slash and not contain any domain or query string.', + '{{#label}} ({{#value}}) is not a valid pathname. Pathname should start with slash and not contain any domain or query string.', ); // Normalized schema for url path segments: baseUrl + routeBasePath... diff --git a/website/_dogfooding/dogfooding.config.js b/website/_dogfooding/dogfooding.config.js index 5e0cf41f64593..a6e2bcde95923 100644 --- a/website/_dogfooding/dogfooding.config.js +++ b/website/_dogfooding/dogfooding.config.js @@ -100,3 +100,30 @@ const dogfoodingPluginInstances = [ ]; exports.dogfoodingPluginInstances = dogfoodingPluginInstances; + +exports.dogfoodingRedirects = [ + { + from: ['/home/'], + to: '/', + }, + { + from: ['/home/qs'], + to: '/?a=1', + }, + { + from: ['/home/anchor'], + to: '/#anchor', + }, + { + from: ['/home/absolute'], + to: 'https://docusaurus.io/', + }, + { + from: ['/home/absolute/qs'], + to: 'https://docusaurus.io/?a=1', + }, + { + from: ['/home/absolute/anchor'], + to: 'https://docusaurus.io/#anchor', + }, +]; diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 7716eaad8401f..3a1c7082e1de6 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -13,6 +13,7 @@ const VersionsArchived = require('./versionsArchived.json'); const { dogfoodingPluginInstances, dogfoodingThemeInstances, + dogfoodingRedirects, } = require('./_dogfooding/dogfooding.config'); /** @type {Record>} */ @@ -260,6 +261,7 @@ module.exports = async function createConfigAsync() { from: ['/docs/resources', '/docs/next/resources'], to: '/community/resources', }, + ...dogfoodingRedirects, ], }), ],