feat(client-redirects-plugin): support fully qualified urls and querystring/hash in destination/to url (#9171)

This commit is contained in:
Sébastien Lorber 2023-07-21 19:54:40 +02:00 committed by GitHub
parent 4ea0a70f93
commit 09ea3bcfab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 260 additions and 44 deletions

View file

@ -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.
"
`;

View file

@ -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."`;

View file

@ -1,5 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`toRedirectFiles creates appropriate metadata absolute url: fileContent 1`] = `
[
"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=https://docusaurus.io/">
<link rel="canonical" href="https://docusaurus.io/" />
</head>
<script>
window.location.href = 'https://docusaurus.io/' + window.location.search + window.location.hash;
</script>
</html>",
"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=https://docusaurus.io/docs/intro?a=1">
<link rel="canonical" href="https://docusaurus.io/docs/intro?a=1" />
</head>
<script>
window.location.href = 'https://docusaurus.io/docs/intro?a=1';
</script>
</html>",
"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=https://docusaurus.io/docs/intro#anchor">
<link rel="canonical" href="https://docusaurus.io/docs/intro#anchor" />
</head>
<script>
window.location.href = 'https://docusaurus.io/docs/intro#anchor';
</script>
</html>",
]
`;
exports[`toRedirectFiles creates appropriate metadata for empty baseUrl: fileContent baseUrl=empty 1`] = `
[
"<!DOCTYPE html>

View file

@ -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',
},
],
},

View file

@ -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',
from: '/fromSomePath?a=1',
to: '/toSomePath',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: '/fromSomePath',
to: '/toSomePath?queryString=xyz',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: null as unknown as string,
to: '/toSomePath?queryString=xyz',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: ['hey'] as unknown as string,
to: '/toSomePath?queryString=xyz',
from: '/fromSomePath#anchor',
to: '/toSomePath',
}),
).toThrowErrorMatchingSnapshot();
});

View file

@ -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',

View file

@ -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 =

View file

@ -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),
});
}

View file

@ -34,6 +34,8 @@ export default function pluginClientRedirectsPages(
siteConfig: props.siteConfig,
};
console.log({propsBaseUrl: props.baseUrl});
const redirects: RedirectItem[] = collectRedirects(
pluginContext,
trailingSlash,

View file

@ -45,11 +45,11 @@ export const DEFAULT_OPTIONS: Partial<PluginOptions> = {
};
const RedirectPluginOptionValidation = Joi.object<RedirectOption>({
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);

View file

@ -10,7 +10,7 @@ import type {RedirectItem} from './types';
const RedirectSchema = Joi.object<RedirectItem>({
from: PathnameSchema.required(),
to: PathnameSchema.required(),
to: Joi.string().required(),
});
export function validateRedirect(redirect: RedirectItem): void {

View file

@ -14,7 +14,7 @@ export default `
<link rel="canonical" href="<%= it.toUrl %>" />
</head>
<script>
window.location.href = '<%= it.toUrl %>' + window.location.search + window.location.hash;
window.location.href = '<%= it.toUrl %>'<%= it.searchAnchorForwarding ? ' + window.location.search + window.location.hash' : '' %>;
</script>
</html>
`;

View file

@ -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

View file

@ -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."`;

View file

@ -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...

View file

@ -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',
},
];

View file

@ -13,6 +13,7 @@ const VersionsArchived = require('./versionsArchived.json');
const {
dogfoodingPluginInstances,
dogfoodingThemeInstances,
dogfoodingRedirects,
} = require('./_dogfooding/dogfooding.config');
/** @type {Record<string,Record<string,string>>} */
@ -260,6 +261,7 @@ module.exports = async function createConfigAsync() {
from: ['/docs/resources', '/docs/next/resources'],
to: '/community/resources',
},
...dogfoodingRedirects,
],
}),
],