Merge pull request #2793 from slorber/feature/client-side-redirects

feat(v2): docusaurus-plugin-client-redirects
This commit is contained in:
Sébastien Lorber 2020-06-10 17:36:57 +02:00 committed by GitHub
commit 68a1bb1ebf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1762 additions and 0 deletions

View file

@ -14,6 +14,7 @@ packages/docusaurus-1.x/lib/core/__tests__/split-tab.test.js
packages/docusaurus-utils/lib/
packages/docusaurus/lib/
packages/docusaurus-init/lib/
packages/docusaurus-plugin-client-redirects/lib/
packages/docusaurus-plugin-content-blog/lib/
packages/docusaurus-plugin-content-docs/lib/
packages/docusaurus-plugin-content-pages/lib/

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ types
packages/docusaurus-utils/lib/
packages/docusaurus/lib/
packages/docusaurus-init/lib/
packages/docusaurus-plugin-client-redirects/lib/
packages/docusaurus-plugin-content-blog/lib/
packages/docusaurus-plugin-content-docs/lib/
packages/docusaurus-plugin-content-pages/lib/

View file

@ -6,6 +6,7 @@ coverage
packages/docusaurus-utils/lib/
packages/docusaurus/lib/
packages/docusaurus-init/lib/
packages/docusaurus-plugin-client-redirects/lib/
packages/docusaurus-init/templates/**/*.md
packages/docusaurus-plugin-content-blog/lib/
packages/docusaurus-plugin-content-docs/lib/

View file

@ -0,0 +1,31 @@
{
"name": "@docusaurus/plugin-client-redirects",
"version": "2.0.0-alpha.56",
"description": "Client redirects plugin for Docusaurus",
"main": "lib/index.js",
"scripts": {
"tsc": "tsc"
},
"publishConfig": {
"access": "public"
},
"license": "MIT",
"dependencies": {
"@docusaurus/types": "^2.0.0-alpha.56",
"@docusaurus/utils": "^2.0.0-alpha.56",
"eta": "^1.1.1",
"globby": "^10.0.1",
"yup": "^0.29.0"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0",
"react": "^16.8.4",
"react-dom": "^16.8.4"
},
"engines": {
"node": ">=10.9.0"
},
"devDependencies": {
"@types/yup": "^0.29.0"
}
}

View file

@ -0,0 +1,29 @@
// 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 array of array redirect 1`] = `
"Some created redirects are invalid:
- {\\"from\\":[\\"/fromPath\\"],\\"to\\":\\"/\\"} => Validation error: from must be a \`string\` type, but the final value was: \`[
\\"\\\\\\"/fromPath\\\\\\"\\"
]\`.
"
`;
exports[`collectRedirects should throw 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 / and not contain any domain or query string
- {\\"from\\":\\"//abc\\",\\"to\\":\\"/\\"} => Validation error: from is not a valid pathname. Pathname should start with / 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 / and not contain any domain or query string
"
`;

View file

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createRedirectPageContent should encode uri special chars 1`] = `
"<!DOCTYPE html>
<html>
<head>
<meta charset=\\"UTF-8\\">
<meta http-equiv=\\"refresh\\" content=\\"0; url=https://docusaurus.io/gr/%CF%83%CE%B5%CE%BB%CE%B9%CE%B4%CE%B1%CF%82/\\">
<link rel=\\"canonical\\" href=\\"https://docusaurus.io/gr/%CF%83%CE%B5%CE%BB%CE%B9%CE%B4%CE%B1%CF%82/\\" />
</head>
<script>
window.location.href = 'https://docusaurus.io/gr/%CF%83%CE%B5%CE%BB%CE%B9%CE%B4%CE%B1%CF%82/';
</script>
</html>"
`;
exports[`createRedirectPageContent should match snapshot 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/';
</script>
</html>"
`;

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`normalizePluginOptions should reject bad createRedirects user inputs 1`] = `
"Invalid @docusaurus/plugin-client-redirects options: createRedirects should be a function
{
\\"createRedirects\\": [
\\"bad\\",
\\"value\\"
]
}"
`;
exports[`normalizePluginOptions should reject bad fromExtensions user inputs 1`] = `
"Invalid @docusaurus/plugin-client-redirects options: fromExtensions[0] must be a \`string\` type, but the final value was: \`null\`.
If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`
{
\\"fromExtensions\\": [
null,
null,
123,
true
]
}"
`;
exports[`normalizePluginOptions should reject bad toExtensions user inputs 1`] = `
"Invalid @docusaurus/plugin-client-redirects options: toExtensions[0] must be a \`string\` type, but the final value was: \`null\`.
If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`
{
\\"toExtensions\\": [
null,
null,
123,
true
]
}"
`;

View file

@ -0,0 +1,11 @@
// 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 / 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 / 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 / and not contain any domain or query string"`;
exports[`validateRedirect throw for bad redirects 4`] = `"{\\"from\\":null,\\"to\\":\\"/toSomePath?queryString=xyz\\"} => Validation error: to is not a valid pathname. Pathname should start with / and not contain any domain or query string"`;
exports[`validateRedirect throw for bad redirects 5`] = `"{\\"from\\":[\\"heyho\\"],\\"to\\":\\"/toSomePath?queryString=xyz\\"} => Validation error: to is not a valid pathname. Pathname should start with / and not contain any domain or query string"`;

View file

@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`toRedirectFilesMetadata should create appropriate metadatas for empty baseUrl: fileContent baseUrl=empty 1`] = `
Array [
"<!DOCTYPE html>
<html>
<head>
<meta charset=\\"UTF-8\\">
<meta http-equiv=\\"refresh\\" content=\\"0; url=/abc/\\">
<link rel=\\"canonical\\" href=\\"/abc/\\" />
</head>
<script>
window.location.href = '/abc/';
</script>
</html>",
]
`;
exports[`toRedirectFilesMetadata should create appropriate metadatas for root baseUrl: fileContent baseUrl=/ 1`] = `
Array [
"<!DOCTYPE html>
<html>
<head>
<meta charset=\\"UTF-8\\">
<meta http-equiv=\\"refresh\\" content=\\"0; url=/abc/\\">
<link rel=\\"canonical\\" href=\\"/abc/\\" />
</head>
<script>
window.location.href = '/abc/';
</script>
</html>",
]
`;
exports[`toRedirectFilesMetadata should create appropriate metadatas: fileContent 1`] = `
Array [
"<!DOCTYPE html>
<html>
<head>
<meta charset=\\"UTF-8\\">
<meta http-equiv=\\"refresh\\" content=\\"0; url=https://docusaurus.io/abc/\\">
<link rel=\\"canonical\\" href=\\"https://docusaurus.io/abc/\\" />
</head>
<script>
window.location.href = 'https://docusaurus.io/abc/';
</script>
</html>",
"<!DOCTYPE html>
<html>
<head>
<meta charset=\\"UTF-8\\">
<meta http-equiv=\\"refresh\\" content=\\"0; url=https://docusaurus.io/def.html/\\">
<link rel=\\"canonical\\" href=\\"https://docusaurus.io/def.html/\\" />
</head>
<script>
window.location.href = 'https://docusaurus.io/def.html/';
</script>
</html>",
"<!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/';
</script>
</html>",
]
`;

View file

@ -0,0 +1,255 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {PluginContext, UserPluginOptions} from '../types';
import collectRedirects from '../collectRedirects';
import normalizePluginOptions from '../normalizePluginOptions';
import {removeTrailingSlash} from '@docusaurus/utils';
function createTestPluginContext(
options?: UserPluginOptions,
routesPaths: string[] = [],
): PluginContext {
return {
outDir: '/tmp',
baseUrl: 'https://docusaurus.io',
routesPaths: routesPaths,
options: normalizePluginOptions(options),
};
}
describe('collectRedirects', () => {
test('should collect no redirect for undefined config', () => {
expect(
collectRedirects(createTestPluginContext(undefined, ['/', '/path'])),
).toEqual([]);
});
test('should collect no redirect for empty config', () => {
expect(collectRedirects(createTestPluginContext({}))).toEqual([]);
});
test('should collect redirects to html/exe extension', () => {
expect(
collectRedirects(
createTestPluginContext(
{
fromExtensions: ['html', 'exe'],
},
['/', '/somePath', '/otherPath.html'],
),
),
).toEqual([
{
from: '/somePath.html',
to: '/somePath',
},
{
from: '/somePath.exe',
to: '/somePath',
},
]);
});
test('should collect redirects to html/exe extension', () => {
expect(
collectRedirects(
createTestPluginContext(
{
toExtensions: ['html', 'exe'],
},
['/', '/somePath', '/otherPath.html'],
),
),
).toEqual([
{
from: '/otherPath',
to: '/otherPath.html',
},
]);
});
test('should collect redirects from plugin option redirects', () => {
expect(
collectRedirects(
createTestPluginContext(
{
redirects: [
{
from: '/someLegacyPath',
to: '/somePath',
},
{
from: ['/someLegacyPathArray1', '/someLegacyPathArray2'],
to: '/',
},
],
},
['/', '/somePath'],
),
),
).toEqual([
{
from: '/someLegacyPath',
to: '/somePath',
},
{
from: '/someLegacyPathArray1',
to: '/',
},
{
from: '/someLegacyPathArray2',
to: '/',
},
]);
});
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(
createTestPluginContext(
{
createRedirects: (routePath) => {
return [
`${removeTrailingSlash(routePath)}/some/path/suffix1`,
`${removeTrailingSlash(routePath)}/some/other/path/suffix2`,
];
},
},
['/', '/testpath', '/otherPath.html'],
),
),
).toEqual([
{
from: '/some/path/suffix1',
to: '/',
},
{
from: '/some/other/path/suffix2',
to: '/',
},
{
from: '/testpath/some/path/suffix1',
to: '/testpath',
},
{
from: '/testpath/some/other/path/suffix2',
to: '/testpath',
},
{
from: '/otherPath.html/some/path/suffix1',
to: '/otherPath.html',
},
{
from: '/otherPath.html/some/other/path/suffix2',
to: '/otherPath.html',
},
]);
});
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 throw if redirect creator creates array of array redirect', () => {
expect(() =>
collectRedirects(
createTestPluginContext(
{
createRedirects: (routePath) => {
if (routePath === '/') {
return [[`/fromPath`]] as any;
}
return;
},
},
['/'],
),
),
).toThrowErrorMatchingSnapshot();
});
test('should filter unwanted redirects', () => {
expect(
collectRedirects(
createTestPluginContext(
{
fromExtensions: ['html', 'exe'],
toExtensions: ['html', 'exe'],
},
[
'/',
'/somePath',
'/somePath.html',
'/somePath.exe',
'/fromShouldWork.html',
'/toShouldWork',
],
),
),
).toEqual([
{
from: '/toShouldWork.html',
to: '/toShouldWork',
},
{
from: '/toShouldWork.exe',
to: '/toShouldWork',
},
{
from: '/fromShouldWork',
to: '/fromShouldWork.html',
},
]);
});
});

View file

@ -0,0 +1,26 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import createRedirectPageContent from '../createRedirectPageContent';
describe('createRedirectPageContent', () => {
test('should match snapshot', () => {
expect(
createRedirectPageContent({toUrl: 'https://docusaurus.io/'}),
).toMatchSnapshot();
});
test('should encode uri special chars', () => {
const result = createRedirectPageContent({
toUrl: 'https://docusaurus.io/gr/σελιδας/',
});
expect(result).toContain(
'https://docusaurus.io/gr/%CF%83%CE%B5%CE%BB%CE%B9%CE%B4%CE%B1%CF%82/',
);
expect(result).toMatchSnapshot();
});
});

View file

@ -0,0 +1,97 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
createFromExtensionsRedirects,
createToExtensionsRedirects,
} from '../extensionRedirects';
import {RedirectMetadata} from '../types';
const createExtensionValidationTests = (
extensionRedirectCreatorFn: (
paths: string[],
extensions: string[],
) => RedirectMetadata[],
) => {
test('should reject empty extensions', () => {
expect(() => {
extensionRedirectCreatorFn(['/'], ['.html']);
}).toThrowErrorMatchingInlineSnapshot(
`"Extension=['.html'] contains a . (dot) and is not allowed. If the redirect extension system is not good enough for your usecase, you can create redirects yourself with the 'createRedirects' plugin option."`,
);
});
test('should reject extensions with .', () => {
expect(() => {
extensionRedirectCreatorFn(['/'], ['.html']);
}).toThrowErrorMatchingInlineSnapshot(
`"Extension=['.html'] contains a . (dot) and is not allowed. If the redirect extension system is not good enough for your usecase, you can create redirects yourself with the 'createRedirects' plugin option."`,
);
});
test('should reject extensions with /', () => {
expect(() => {
extensionRedirectCreatorFn(['/'], ['ht/ml']);
}).toThrowErrorMatchingInlineSnapshot(
`"Extension=['ht/ml'] contains a / and is not allowed. If the redirect extension system is not good enough for your usecase, you can create redirects yourself with the 'createRedirects' plugin option."`,
);
});
test('should reject extensions with illegal url char', () => {
expect(() => {
extensionRedirectCreatorFn(['/'], [',']);
}).toThrowErrorMatchingInlineSnapshot(
`"Extension=[','] contains invalid uri characters. If the redirect extension system is not good enough for your usecase, you can create redirects yourself with the 'createRedirects' plugin option."`,
);
});
};
describe('createToExtensionsRedirects', () => {
createExtensionValidationTests(createToExtensionsRedirects);
test('should create redirects from html/htm extensions', () => {
const ext = ['html', 'htm'];
expect(createToExtensionsRedirects([''], ext)).toEqual([]);
expect(createToExtensionsRedirects(['/'], ext)).toEqual([]);
expect(createToExtensionsRedirects(['/abc.html'], ext)).toEqual([
{from: '/abc', to: '/abc.html'},
]);
expect(createToExtensionsRedirects(['/abc.htm'], ext)).toEqual([
{from: '/abc', to: '/abc.htm'},
]);
expect(createToExtensionsRedirects(['/abc.xyz'], ext)).toEqual([]);
});
test('should not create redirection for an empty extension array', () => {
const ext: string[] = [];
expect(createToExtensionsRedirects([''], ext)).toEqual([]);
expect(createToExtensionsRedirects(['/'], ext)).toEqual([]);
expect(createToExtensionsRedirects(['/abc.html'], ext)).toEqual([]);
});
});
describe('createFromExtensionsRedirects', () => {
createExtensionValidationTests(createFromExtensionsRedirects);
test('should create redirects to html/htm extensions', () => {
const ext = ['html', 'htm'];
expect(createFromExtensionsRedirects([''], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/'], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/abc'], ext)).toEqual([
{from: '/abc.html', to: '/abc'},
{from: '/abc.htm', to: '/abc'},
]);
expect(createFromExtensionsRedirects(['/def.html'], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/def/'], ext)).toEqual([]);
});
test('should not create redirection for an empty extension array', () => {
const ext: string[] = [];
expect(createFromExtensionsRedirects([''], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/'], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/abc'], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/def.html'], ext)).toEqual([]);
expect(createFromExtensionsRedirects(['/def/'], ext)).toEqual([]);
});
});

View file

@ -0,0 +1,72 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import normalizePluginOptions, {
DefaultPluginOptions,
} from '../normalizePluginOptions';
import {CreateRedirectsFnOption} from '../types';
describe('normalizePluginOptions', () => {
test('should return default options for undefined user options', () => {
expect(normalizePluginOptions()).toEqual(DefaultPluginOptions);
});
test('should return default options for empty user options', () => {
expect(normalizePluginOptions()).toEqual(DefaultPluginOptions);
});
test('should override one default options with valid user options', () => {
expect(
normalizePluginOptions({
toExtensions: ['html'],
}),
).toEqual({...DefaultPluginOptions, toExtensions: ['html']});
});
test('should override all default options with valid user options', () => {
const createRedirects: CreateRedirectsFnOption = (_routePath: string) => {
return [];
};
expect(
normalizePluginOptions({
fromExtensions: ['exe', 'zip'],
toExtensions: ['html'],
createRedirects,
redirects: [{from: '/x', to: '/y'}],
}),
).toEqual({
fromExtensions: ['exe', 'zip'],
toExtensions: ['html'],
createRedirects,
redirects: [{from: '/x', to: '/y'}],
});
});
test('should reject bad fromExtensions user inputs', () => {
expect(() =>
normalizePluginOptions({
fromExtensions: [null, undefined, 123, true] as any,
}),
).toThrowErrorMatchingSnapshot();
});
test('should reject bad toExtensions user inputs', () => {
expect(() =>
normalizePluginOptions({
toExtensions: [null, undefined, 123, true] as any,
}),
).toThrowErrorMatchingSnapshot();
});
test('should reject bad createRedirects user inputs', () => {
expect(() =>
normalizePluginOptions({
createRedirects: ['bad', 'value'] as any,
}),
).toThrowErrorMatchingSnapshot();
});
});

View file

@ -0,0 +1,66 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {validateRedirect} from '../redirectValidation';
describe('validateRedirect', () => {
test('validate good redirects without throwing', () => {
validateRedirect({
from: '/fromSomePath',
to: '/toSomePath',
});
validateRedirect({
from: '/from/Some/Path',
to: '/toSomePath',
});
validateRedirect({
from: '/fromSomePath',
to: '/toSomePath',
});
validateRedirect({
from: '/fromSomePath',
to: '/to/Some/Path',
});
});
test('throw for bad redirects', () => {
expect(() =>
validateRedirect({
from: 'https://fb.com/fromSomePath',
to: '/toSomePath',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: '/fromSomePath',
to: 'https://fb.com/toSomePath',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: '/fromSomePath',
to: '/toSomePath?queryString=xyz',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: null as any,
to: '/toSomePath?queryString=xyz',
}),
).toThrowErrorMatchingSnapshot();
expect(() =>
validateRedirect({
from: ['heyho'] as any,
to: '/toSomePath?queryString=xyz',
}),
).toThrowErrorMatchingSnapshot();
});
});

View file

@ -0,0 +1,116 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import fs from 'fs-extra';
import path from 'path';
import writeRedirectFiles, {
toRedirectFilesMetadata,
} from '../writeRedirectFiles';
describe('toRedirectFilesMetadata', () => {
test('should create appropriate metadatas', async () => {
const pluginContext = {
outDir: '/tmp/someFixedOutDir',
baseUrl: 'https://docusaurus.io',
};
const redirectFiles = toRedirectFilesMetadata(
[
{from: '/abc.html', to: '/abc'},
{from: '/def', to: '/def.html'},
{from: '/xyz', to: '/'},
],
pluginContext,
);
expect(redirectFiles.map((f) => f.fileAbsolutePath)).toEqual([
path.join(pluginContext.outDir, '/abc.html/index.html'),
path.join(pluginContext.outDir, '/def/index.html'),
path.join(pluginContext.outDir, '/xyz/index.html'),
]);
expect(redirectFiles.map((f) => f.fileContent)).toMatchSnapshot(
'fileContent',
);
});
test('should create appropriate metadatas for root baseUrl', async () => {
const pluginContext = {
outDir: '/tmp/someFixedOutDir',
baseUrl: '/',
};
const redirectFiles = toRedirectFilesMetadata(
[{from: '/abc.html', to: '/abc'}],
pluginContext,
);
expect(redirectFiles.map((f) => f.fileContent)).toMatchSnapshot(
'fileContent baseUrl=/',
);
});
test('should create appropriate metadatas for empty baseUrl', async () => {
const pluginContext = {
outDir: '/tmp/someFixedOutDir',
baseUrl: '',
};
const redirectFiles = toRedirectFilesMetadata(
[{from: '/abc.html', to: '/abc'}],
pluginContext,
);
expect(redirectFiles.map((f) => f.fileContent)).toMatchSnapshot(
'fileContent baseUrl=empty',
);
});
});
describe('writeRedirectFiles', () => {
test('write the files', async () => {
const outDir = '/tmp/docusaurus_tests_' + Math.random();
const filesMetadata = [
{
fileAbsolutePath: path.join(outDir, 'someFileName'),
fileContent: 'content 1',
},
{
fileAbsolutePath: path.join(outDir, '/some/nested/filename'),
fileContent: 'content 2',
},
];
await writeRedirectFiles(filesMetadata);
await expect(
fs.readFile(filesMetadata[0].fileAbsolutePath, 'utf8'),
).resolves.toEqual('content 1');
await expect(
fs.readFile(filesMetadata[1].fileAbsolutePath, 'utf8'),
).resolves.toEqual('content 2');
});
test('avoid overwriting existing files', async () => {
const outDir = '/tmp/docusaurus_tests_' + Math.random();
const filesMetadata = [
{
fileAbsolutePath: path.join(outDir, 'someFileName/index.html'),
fileContent: 'content 1',
},
];
await fs.ensureDir(path.dirname(filesMetadata[0].fileAbsolutePath));
await fs.writeFile(
filesMetadata[0].fileAbsolutePath,
'file already exists!',
);
await expect(writeRedirectFiles(filesMetadata)).rejects.toThrowError(
`Redirect file creation error for path=${filesMetadata[0].fileAbsolutePath}: Error: The redirect plugin is not supposed to override existing files`,
);
});
});

View file

@ -0,0 +1,168 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {flatten, uniqBy, difference, groupBy} from 'lodash';
import {
PluginContext,
RedirectMetadata,
PluginOptions,
RedirectOption,
} from './types';
import {
createFromExtensionsRedirects,
createToExtensionsRedirects,
} from './extensionRedirects';
import {validateRedirect} from './redirectValidation';
import chalk from 'chalk';
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.to);
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,
): RedirectMetadata[] {
// we don't want to create twice the same redirect
// that would lead to writing twice the same html redirection file
Object.entries(groupBy(redirects, (redirect) => redirect.from)).forEach(
([from, groupedFromRedirects]) => {
if (groupedFromRedirects.length > 1) {
console.error(
chalk.red(
`@docusaurus/plugin-client-redirects: multiple redirects are created with the same "from" pathname=${from}
It is not possible to redirect the same pathname to multiple destinations:
- ${groupedFromRedirects.map((r) => JSON.stringify(r)).join('\n- ')}
`,
),
);
}
},
);
redirects = uniqBy(redirects, (redirect) => redirect.from);
// We don't want to override an already existing route with a redirect file!
const redirectsOverridingExistingPath = redirects.filter((redirect) =>
pluginContext.routesPaths.includes(redirect.from),
);
if (redirectsOverridingExistingPath.length > 0) {
console.error(
chalk.red(
`@docusaurus/plugin-client-redirects: some redirects would override existing paths, and will be ignored:
- ${redirectsOverridingExistingPath.map((r) => JSON.stringify(r)).join('\n- ')}
`,
),
);
}
redirects = redirects.filter(
(redirect) => !pluginContext.routesPaths.includes(redirect.from),
);
return redirects;
}
// For each plugin config option, create the appropriate redirects
function doCollectRedirects(pluginContext: PluginContext): RedirectMetadata[] {
return [
...createFromExtensionsRedirects(
pluginContext.routesPaths,
pluginContext.options.fromExtensions,
),
...createToExtensionsRedirects(
pluginContext.routesPaths,
pluginContext.options.toExtensions,
),
...createRedirectsOptionRedirects(pluginContext.options.redirects),
...createCreateRedirectsOptionRedirects(
pluginContext.routesPaths,
pluginContext.options.createRedirects,
),
];
}
function createRedirectsOptionRedirects(
redirectsOption: PluginOptions['redirects'],
): RedirectMetadata[] {
// For conveniency, user can use a string or a string[]
function optionToRedirects(option: RedirectOption): RedirectMetadata[] {
if (typeof option.from === 'string') {
return [{from: option.from, to: option.to}];
} else {
return option.from.map((from) => ({
from,
to: option.to,
}));
}
}
return flatten(redirectsOption.map(optionToRedirects));
}
// Create redirects from the "createRedirects" fn provided by the user
function createCreateRedirectsOptionRedirects(
paths: string[],
createRedirects: PluginOptions['createRedirects'],
): RedirectMetadata[] {
function createPathRedirects(path: string): RedirectMetadata[] {
const fromsMixed: string | string[] = createRedirects
? createRedirects(path) || []
: [];
const froms: string[] =
typeof fromsMixed === 'string' ? [fromsMixed] : fromsMixed;
return froms.map((from) => {
return {
from,
to: path,
};
});
}
return flatten(paths.map(createPathRedirects));
}

View file

@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as eta from 'eta';
import redirectPageTemplate from './templates/redirectPage.template.html';
type CreateRedirectPageOptions = {
toUrl: string;
};
export default function createRedirectPageContent({
toUrl,
}: CreateRedirectPageOptions) {
return eta.render(redirectPageTemplate.trim(), {
toUrl: encodeURI(toUrl),
});
}

View file

@ -0,0 +1,90 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {flatten} from 'lodash';
import {removeSuffix} from '@docusaurus/utils';
import {RedirectMetadata} from './types';
const ExtensionAdditionalMessage =
"If the redirect extension system is not good enough for your usecase, you can create redirects yourself with the 'createRedirects' plugin option.";
const validateExtension = (ext: string) => {
if (!ext) {
throw new Error(
`Extension=['${String(
ext,
)}'] is not allowed. ${ExtensionAdditionalMessage}`,
);
}
if (ext.includes('.')) {
throw new Error(
`Extension=['${ext}'] contains a . (dot) and is not allowed. ${ExtensionAdditionalMessage}`,
);
}
if (ext.includes('/')) {
throw new Error(
`Extension=['${ext}'] contains a / and is not allowed. ${ExtensionAdditionalMessage}`,
);
}
if (encodeURIComponent(ext) !== ext) {
throw new Error(
`Extension=['${ext}'] contains invalid uri characters. ${ExtensionAdditionalMessage}`,
);
}
};
const addLeadingDot = (extension: string) => `.${extension}`;
// Create new /path that redirects to existing an /path.html
export function createToExtensionsRedirects(
paths: string[],
extensions: string[],
): RedirectMetadata[] {
extensions.forEach(validateExtension);
const dottedExtensions = extensions.map(addLeadingDot);
const createPathRedirects = (path: string): RedirectMetadata[] => {
const extensionFound = dottedExtensions.find((ext) => path.endsWith(ext));
if (extensionFound) {
const routePathWithoutExtension = removeSuffix(path, extensionFound);
return [routePathWithoutExtension].map((from) => ({
from: from,
to: path,
}));
}
return [];
};
return flatten(paths.map(createPathRedirects));
}
// Create new /path.html that redirects to existing an /path
export function createFromExtensionsRedirects(
paths: string[],
extensions: string[],
): RedirectMetadata[] {
extensions.forEach(validateExtension);
const dottedExtensions = extensions.map(addLeadingDot);
const alreadyEndsWithAnExtension = (str: string) =>
dottedExtensions.some((ext) => str.endsWith(ext));
const createPathRedirects = (path: string): RedirectMetadata[] => {
if (path === '' || path.endsWith('/') || alreadyEndsWithAnExtension(path)) {
return [];
} else {
return extensions.map((ext) => ({
from: `${path}.${ext}`,
to: path,
}));
}
};
return flatten(paths.map(createPathRedirects));
}

View file

@ -0,0 +1,45 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {LoadContext, Plugin, Props} from '@docusaurus/types';
import {UserPluginOptions, PluginContext, RedirectMetadata} from './types';
import normalizePluginOptions from './normalizePluginOptions';
import collectRedirects from './collectRedirects';
import writeRedirectFiles, {
toRedirectFilesMetadata,
RedirectFileMetadata,
} from './writeRedirectFiles';
export default function pluginClientRedirectsPages(
_context: LoadContext,
opts: UserPluginOptions,
): Plugin<unknown> {
const options = normalizePluginOptions(opts);
return {
name: 'docusaurus-plugin-client-redirects',
async postBuild(props: Props) {
const pluginContext: PluginContext = {
routesPaths: props.routesPaths,
baseUrl: props.baseUrl,
outDir: props.outDir,
options,
};
const redirects: RedirectMetadata[] = collectRedirects(pluginContext);
const redirectFiles: RedirectFileMetadata[] = toRedirectFilesMetadata(
redirects,
pluginContext,
);
// Write files only at the end: make code more easy to test without IO
await writeRedirectFiles(redirectFiles);
},
};
}

View file

@ -0,0 +1,72 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
PluginOptions,
RedirectOption,
CreateRedirectsFnOption,
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 CreateRedirectsFnOption | undefined {
if (value === null || typeof value === 'undefined') {
return true;
}
return value instanceof Function;
}
const RedirectPluginOptionValidation = Yup.object<RedirectOption>({
to: PathnameValidator.required(),
// See https://stackoverflow.com/a/62177080/82609
from: Yup.lazy<string | string[]>((from) => {
return Array.isArray(from)
? Yup.array().of(PathnameValidator.required()).required()
: PathnameValidator.required();
}),
});
const UserOptionsSchema = Yup.object().shape<UserPluginOptions>({
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',
isRedirectsCreator,
),
});
function validateUserOptions(userOptions: UserPluginOptions) {
try {
UserOptionsSchema.validateSync(userOptions, {
strict: true,
abortEarly: true,
});
} catch (e) {
throw new Error(
`Invalid @docusaurus/plugin-client-redirects options: ${e.message}
${JSON.stringify(userOptions, null, 2)}`,
);
}
}
export default function normalizePluginOptions(
userPluginOptions: UserPluginOptions = {},
): PluginOptions {
validateUserOptions(userPluginOptions);
return {...DefaultPluginOptions, ...userPluginOptions};
}

View file

@ -0,0 +1,36 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {isValidPathname} from '@docusaurus/utils';
import * as Yup from 'yup';
import {RedirectMetadata} from './types';
export const PathnameValidator = Yup.string().test({
name: 'isValidPathname',
message:
'${path} is not a valid pathname. Pathname should start with / and not contain any domain or query string',
test: isValidPathname,
});
const RedirectSchema = Yup.object<RedirectMetadata>({
from: PathnameValidator.required(),
to: PathnameValidator.required(),
});
export function validateRedirect(redirect: RedirectMetadata) {
try {
RedirectSchema.validateSync(redirect, {
strict: true,
abortEarly: true,
});
} catch (e) {
// Tells the user which redirect is the problem!
throw new Error(
`${JSON.stringify(redirect)} => Validation error: ${e.message}`,
);
}
}

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export default `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=<%= it.toUrl %>">
<link rel="canonical" href="<%= it.toUrl %>" />
</head>
<script>
window.location.href = '<%= it.toUrl %>';
</script>
</html>
`;

View file

@ -0,0 +1,44 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {Props} from '@docusaurus/types';
export type PluginOptions = {
fromExtensions: string[];
toExtensions: string[];
redirects: RedirectOption[];
createRedirects?: CreateRedirectsFnOption;
};
// For a given existing route path,
// return all the paths from which we should redirect from
export type CreateRedirectsFnOption = (
path: string,
) => string[] | string | null | undefined;
export type RedirectOption = {
to: string;
from: string | string[];
};
export type UserPluginOptions = Partial<PluginOptions>;
// The minimal infos the plugin needs to work
export type PluginContext = Pick<
Props,
'routesPaths' | 'outDir' | 'baseUrl'
> & {
options: PluginOptions;
};
// In-memory representation of redirects we want: easier to test
// /!\ easy to be confused: "from" is the new page we should create,
// that redirects to "to": the existing Docusaurus page
export type RedirectMetadata = {
from: string; // pathname
to: string; // pathname
};

View file

@ -0,0 +1,84 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import fs from 'fs-extra';
import path from 'path';
import {memoize} from 'lodash';
import {PluginContext, RedirectMetadata} from './types';
import createRedirectPageContent from './createRedirectPageContent';
import {
addTrailingSlash,
getFilePathForRoutePath,
removeTrailingSlash,
} from '@docusaurus/utils';
export type WriteFilesPluginContext = Pick<PluginContext, 'baseUrl' | 'outDir'>;
export type RedirectFileMetadata = {
fileAbsolutePath: string;
fileContent: string;
};
export function toRedirectFilesMetadata(
redirects: RedirectMetadata[],
pluginContext: WriteFilesPluginContext,
): RedirectFileMetadata[] {
// Perf: avoid rendering the template twice with the exact same "props"
// We might create multiple redirect pages for the same destination url
// note: the first fn arg is the cache key!
const createPageContentMemoized = memoize((toUrl: string) => {
return createRedirectPageContent({toUrl});
});
const createFileMetadata = (redirect: RedirectMetadata) => {
const fileAbsolutePath = path.join(
pluginContext.outDir,
getFilePathForRoutePath(redirect.from),
);
const toUrl = addTrailingSlash(
`${removeTrailingSlash(pluginContext.baseUrl)}${path.join(redirect.to)}`,
);
const fileContent = createPageContentMemoized(toUrl);
return {
...redirect,
fileAbsolutePath,
fileContent,
};
};
return redirects.map(createFileMetadata);
}
export async function writeRedirectFile(file: RedirectFileMetadata) {
try {
// User-friendly security to prevent file overrides
if (await fs.pathExists(file.fileAbsolutePath)) {
throw new Error(
'The redirect plugin is not supposed to override existing files',
);
}
await fs.ensureDir(path.dirname(file.fileAbsolutePath));
await fs.writeFile(
file.fileAbsolutePath,
file.fileContent,
// Hard security to prevent file overrides
// See https://stackoverflow.com/a/34187712/82609
{flag: 'wx'},
);
} catch (err) {
throw new Error(
`Redirect file creation error for path=${file.fileAbsolutePath}: ${err}`,
);
}
}
export default async function writeRedirectFiles(
redirectFiles: RedirectFileMetadata[],
) {
await Promise.all(redirectFiles.map(writeRedirectFile));
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"rootDir": "src",
"outDir": "lib"
}
}

View file

@ -18,6 +18,11 @@ import {
objectWithKeySorted,
aliasedSitePath,
createExcerpt,
isValidPathname,
addTrailingSlash,
removeTrailingSlash,
removeSuffix,
getFilePathForRoutePath,
} from '../index';
describe('load utils', () => {
@ -363,4 +368,69 @@ describe('load utils', () => {
expect(createExcerpt(testCase.input)).toEqual(testCase.output);
});
});
test('isValidPathname', () => {
expect(isValidPathname('/')).toBe(true);
expect(isValidPathname('/hey')).toBe(true);
expect(isValidPathname('/hey/ho')).toBe(true);
expect(isValidPathname('/hey/ho/')).toBe(true);
expect(isValidPathname('/hey/h%C3%B4/')).toBe(true);
expect(isValidPathname('/hey///ho///')).toBe(true); // Unexpected but valid
//
expect(isValidPathname('')).toBe(false);
expect(isValidPathname('hey')).toBe(false);
expect(isValidPathname('/hey/hô')).toBe(false);
expect(isValidPathname('/hey?qs=ho')).toBe(false);
expect(isValidPathname('https://fb.com/hey')).toBe(false);
expect(isValidPathname('//hey')).toBe(false);
});
});
describe('addTrailingSlash', () => {
test('should no-op', () => {
expect(addTrailingSlash('/abcd/')).toEqual('/abcd/');
});
test('should add /', () => {
expect(addTrailingSlash('/abcd')).toEqual('/abcd/');
});
});
describe('removeTrailingSlash', () => {
test('should no-op', () => {
expect(removeTrailingSlash('/abcd')).toEqual('/abcd');
});
test('should remove /', () => {
expect(removeTrailingSlash('/abcd/')).toEqual('/abcd');
});
});
describe('removeSuffix', () => {
test('should no-op 1', () => {
expect(removeSuffix('abcdef', 'ijk')).toEqual('abcdef');
});
test('should no-op 2', () => {
expect(removeSuffix('abcdef', 'abc')).toEqual('abcdef');
});
test('should no-op 3', () => {
expect(removeSuffix('abcdef', '')).toEqual('abcdef');
});
test('should remove suffix', () => {
expect(removeSuffix('abcdef', 'ef')).toEqual('abcd');
});
});
describe('getFilePathForRoutePath', () => {
test('works for /', () => {
expect(getFilePathForRoutePath('/')).toEqual('/index.html');
});
test('works for /somePath', () => {
expect(getFilePathForRoutePath('/somePath')).toEqual(
'/somePath/index.html',
);
});
test('works for /somePath/', () => {
expect(getFilePathForRoutePath('/somePath/')).toEqual(
'/somePath/index.html',
);
});
});

View file

@ -12,6 +12,7 @@ import camelCase from 'lodash.camelcase';
import kebabCase from 'lodash.kebabcase';
import escapeStringRegexp from 'escape-string-regexp';
import fs from 'fs-extra';
import {URL} from 'url';
const fileHash = new Map();
export async function generate(
@ -349,3 +350,35 @@ export function getEditUrl(fileRelativePath: string, editUrl?: string) {
? normalizeUrl([editUrl, posixPath(fileRelativePath)])
: undefined;
}
export function isValidPathname(str: string): boolean {
if (!str.startsWith('/')) {
return false;
}
try {
return new URL(str, 'https://domain.com').pathname === str;
} catch (e) {
return false;
}
}
export function addTrailingSlash(str: string) {
return str.endsWith('/') ? str : `${str}/`;
}
export function removeTrailingSlash(str: string) {
return removeSuffix(str, '/');
}
export function removeSuffix(str: string, suffix: string) {
if (suffix === '') {
return str; // always returns "" otherwise!
}
return str.endsWith(suffix) ? str.slice(0, -suffix.length) : str;
}
export function getFilePathForRoutePath(routePath: string) {
const fileName = path.basename(routePath);
const filePath = path.dirname(routePath);
return path.join(filePath, `${fileName}/index.html`);
}

View file

@ -448,6 +448,43 @@ The following fields are all deprecated, you may remove from your configuration
We intend to implement many of the deprecated config fields as plugins in future. Help will be appreciated!
## Urls
In v1, all pages were available with or without the `.html` extension.
For example, these 2 pages exist:
- [https://docusaurus.io/docs/en/installation](https://docusaurus.io/docs/en/installation)
- [https://docusaurus.io/docs/en/installation.html](https://docusaurus.io/docs/en/installation.html)
If [`cleanUrl`](https://docusaurus.io/docs/en/site-config#cleanurl-boolean) was:
- `true`: links would target `/installation`
- `false`: links would target `/installation.html`
In v2, by default, the canonical page is `/installation`, and not `/installation.html`.
If you had `cleanUrl: false` in v1, it's possible that people published links to `/installation.html`.
For SEO reasons, and avoiding breaking links, you should configure server-side redirect rules on your hosting provider.
As an escape hatch, you could use [@docusaurus/plugin-client-redirects](./using-plugins.md#docusaurusplugin-client-redirects) to create client-side redirects from `/installation.html` to `/installation`.
```js
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
fromExtensions: ['html'],
},
],
],
};
```
If you want to keep the `.html` extension as the canonical url of a page, docs can declare a `slug: installation.html` frontmatter.
## Components
### Sidebar

View file

@ -458,3 +458,123 @@ import thumbnail from './path/to/img.png';
| `max` | `integer` | | See `min` above |
| `steps` | `integer` | `4` | Configure the number of images generated between `min` and `max` (inclusive) |
| `quality` | `integer` | `85` | JPEG compression quality |
### `@docusaurus/plugin-client-redirects`
Docusaurus Plugin to generate **client-side redirects**.
This plugin will write additional HTML pages to your static site, that redirects the user to your existing Docusaurus pages with JavaScript.
:::caution
It is better to use server-side redirects whenever possible.
Before using this plugin, you should look if your hosting provider doesn't offer this feature.
:::
**Installation**
```bash npm2yarn
npm install --save @docusaurus/plugin-client-redirects
```
**Configuration**
Main usecase: you have `/myDocusaurusPage`, and you want to redirect to this page from `/myDocusaurusPage.html`:
```js title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
fromExtensions: ['html'],
},
],
],
};
```
Second usecase: you have `/myDocusaurusPage.html`, and you want to redirect to this page from `/myDocusaurusPage`.
```js title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
toExtensions: ['html'],
},
],
],
};
```
For custom redirect logic, provide your own `createRedirects` function.
Let's imagine you change the url of an existing page, you might want to make sure the old url still works:
```js title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
redirects: [
{
to: '/docs/newDocPath', // string
from: ['/docs/oldDocPathFrom2019', '/docs/legacyDocPathFrom2016'], // string | string[]
},
],
},
],
],
};
```
It's possible to use a function to create the redirects for each existing path:
```js title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
createRedirects: function (existingPath) {
if (existingPath === '/docs/newDocPath') {
return ['/docs/oldDocPathFrom2019', '/docs/legacyDocPathFrom2016']; // string | string[]
}
},
},
],
],
};
```
Finally, it's possible to use all options at the same time:
```js title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
fromExtensions: ['html', 'htm'],
toExtensions: ['exe', 'zip'],
redirects: [
{
to: '/docs/newDocPath',
from: '/docs/oldDocPath',
},
],
createRedirects: function (existingPath) {
if (existingPath === '/docs/newDocPath2') {
return ['/docs/oldDocPath2'];
}
},
},
],
],
};
```

View file

@ -7,6 +7,12 @@
const versions = require('./versions.json');
const allDocHomesPaths = [
'/docs',
'/docs/next',
...versions.slice(1).map((version) => `/docs/${version}`),
];
module.exports = {
title: 'Docusaurus',
tagline: 'Build optimized websites quickly, focus on your content',
@ -21,6 +27,19 @@ module.exports = {
},
themes: ['@docusaurus/theme-live-codeblock'],
plugins: [
[
'@docusaurus/plugin-client-redirects',
{
fromExtensions: ['html'],
createRedirects: function (path) {
// redirect to /docs from /docs/introduction,
// as introduction has been made the home doc
if (allDocHomesPaths.includes(path)) {
return [`${path}/introduction`];
}
},
},
],
[
'@docusaurus/plugin-ideal-image',
{

View file

@ -11,6 +11,7 @@
"dependencies": {
"@docusaurus/core": "^2.0.0-alpha.56",
"@docusaurus/plugin-ideal-image": "^2.0.0-alpha.56",
"@docusaurus/plugin-client-redirects": "^2.0.0-alpha.56",
"@docusaurus/preset-classic": "^2.0.0-alpha.56",
"@docusaurus/theme-live-codeblock": "^2.0.0-alpha.56",
"clsx": "^1.1.1",

View file

@ -1156,6 +1156,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.6":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@ -3135,6 +3142,11 @@
dependencies:
"@types/yargs-parser" "*"
"@types/yup@^0.29.0":
version "0.29.0"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.0.tgz#0918ec503dfacb19d0b3cca0195b9f3441f46685"
integrity sha512-E9RTXPD4x44qBOvY6TjUqdkR9FNV9cACWlnAsooUInDqtLZz9M9oYXKn/w1GHNxRvyYyHuG6Bfjbg3QlK+SgXw==
"@webassemblyjs/ast@1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
@ -7658,6 +7670,11 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
fn-name@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-3.0.0.tgz#0596707f635929634d791f452309ab41558e3c5c"
integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
follow-redirects@^1.0.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
@ -10720,6 +10737,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash-es@^4.17.11:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@ -13936,6 +13958,11 @@ prop-types@^15.0.0, prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.5.8,
object-assign "^4.1.1"
react-is "^16.8.1"
property-expr@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330"
integrity sha512-bc/5ggaYZxNkFKj374aLbEDqVADdYaLcFo8XBkishUWbaAdjlphaBFns9TvRA2pUseVL/wMFmui9X3IdNDU37g==
property-information@^5.0.0, property-information@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.3.0.tgz#bc87ac82dc4e72a31bb62040544b1bf9653da039"
@ -16483,6 +16510,11 @@ symbol-tree@^3.2.2:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
synchronous-promise@^2.0.10:
version "2.0.13"
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.13.tgz#9d8c165ddee69c5a6542862b405bc50095926702"
integrity sha512-R9N6uDkVsghHePKh1TEqbnLddO2IY25OcsksyFp/qBe7XYd0PVbKEWxhcdMhpLzE1I6skj5l4aEZ3CRxcbArlA==
table@^5.2.3, table@^5.4.6:
version "5.4.6"
resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
@ -16845,6 +16877,11 @@ toml@^2.3.2:
resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.6.tgz#25b0866483a9722474895559088b436fd11f861b"
integrity sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=
tough-cookie@^2.3.3, tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@ -18177,6 +18214,19 @@ yauzl@^2.4.2:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
yup@^0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/yup/-/yup-0.29.0.tgz#c0670897b2ebcea42ebde12b3567f55ea3a7acaf"
integrity sha512-rXPkxhMIVPsQ6jZXPRcO+nc+AIT+BBo3012pmiEos2RSrPxAq1LyspZyK7l14ahcXuiKQnEHI0H5bptI47v5Tw==
dependencies:
"@babel/runtime" "^7.9.6"
fn-name "~3.0.0"
lodash "^4.17.15"
lodash-es "^4.17.11"
property-expr "^2.0.2"
synchronous-promise "^2.0.10"
toposort "^2.0.2"
zepto@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/zepto/-/zepto-1.2.0.tgz#e127bd9e66fd846be5eab48c1394882f7c0e4f98"