mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-03 20:27:20 +02:00
feat(client-redirects-plugin): support fully qualified urls and querystring/hash in destination/to url (#9171)
This commit is contained in:
parent
4ea0a70f93
commit
09ea3bcfab
17 changed files with 260 additions and 44 deletions
|
@ -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.
|
||||
"
|
||||
`;
|
||||
|
|
|
@ -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."`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -34,6 +34,8 @@ export default function pluginClientRedirectsPages(
|
|||
siteConfig: props.siteConfig,
|
||||
};
|
||||
|
||||
console.log({propsBaseUrl: props.baseUrl});
|
||||
|
||||
const redirects: RedirectItem[] = collectRedirects(
|
||||
pluginContext,
|
||||
trailingSlash,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -23,8 +23,11 @@ export type RedirectFile = {
|
|||
};
|
||||
|
||||
export function createToUrl(baseUrl: string, to: string): string {
|
||||
if (to.startsWith('/')) {
|
||||
return normalizeUrl([baseUrl, to]);
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
// Create redirect file path
|
||||
// Make sure this path has lower precedence over the original file path when
|
||||
|
|
|
@ -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."`;
|
||||
|
||||
|
|
|
@ -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...
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
|
Loading…
Add table
Reference in a new issue