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-utils/lib/
packages/docusaurus/lib/ packages/docusaurus/lib/
packages/docusaurus-init/lib/ packages/docusaurus-init/lib/
packages/docusaurus-plugin-client-redirects/lib/
packages/docusaurus-plugin-content-blog/lib/ packages/docusaurus-plugin-content-blog/lib/
packages/docusaurus-plugin-content-docs/lib/ packages/docusaurus-plugin-content-docs/lib/
packages/docusaurus-plugin-content-pages/lib/ packages/docusaurus-plugin-content-pages/lib/

1
.gitignore vendored
View file

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

View file

@ -6,6 +6,7 @@ coverage
packages/docusaurus-utils/lib/ packages/docusaurus-utils/lib/
packages/docusaurus/lib/ packages/docusaurus/lib/
packages/docusaurus-init/lib/ packages/docusaurus-init/lib/
packages/docusaurus-plugin-client-redirects/lib/
packages/docusaurus-init/templates/**/*.md packages/docusaurus-init/templates/**/*.md
packages/docusaurus-plugin-content-blog/lib/ packages/docusaurus-plugin-content-blog/lib/
packages/docusaurus-plugin-content-docs/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, objectWithKeySorted,
aliasedSitePath, aliasedSitePath,
createExcerpt, createExcerpt,
isValidPathname,
addTrailingSlash,
removeTrailingSlash,
removeSuffix,
getFilePathForRoutePath,
} from '../index'; } from '../index';
describe('load utils', () => { describe('load utils', () => {
@ -363,4 +368,69 @@ describe('load utils', () => {
expect(createExcerpt(testCase.input)).toEqual(testCase.output); 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 kebabCase from 'lodash.kebabcase';
import escapeStringRegexp from 'escape-string-regexp'; import escapeStringRegexp from 'escape-string-regexp';
import fs from 'fs-extra'; import fs from 'fs-extra';
import {URL} from 'url';
const fileHash = new Map(); const fileHash = new Map();
export async function generate( export async function generate(
@ -349,3 +350,35 @@ export function getEditUrl(fileRelativePath: string, editUrl?: string) {
? normalizeUrl([editUrl, posixPath(fileRelativePath)]) ? normalizeUrl([editUrl, posixPath(fileRelativePath)])
: undefined; : 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! 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 ## Components
### Sidebar ### Sidebar

View file

@ -458,3 +458,123 @@ import thumbnail from './path/to/img.png';
| `max` | `integer` | | See `min` above | | `max` | `integer` | | See `min` above |
| `steps` | `integer` | `4` | Configure the number of images generated between `min` and `max` (inclusive) | | `steps` | `integer` | `4` | Configure the number of images generated between `min` and `max` (inclusive) |
| `quality` | `integer` | `85` | JPEG compression quality | | `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 versions = require('./versions.json');
const allDocHomesPaths = [
'/docs',
'/docs/next',
...versions.slice(1).map((version) => `/docs/${version}`),
];
module.exports = { module.exports = {
title: 'Docusaurus', title: 'Docusaurus',
tagline: 'Build optimized websites quickly, focus on your content', tagline: 'Build optimized websites quickly, focus on your content',
@ -21,6 +27,19 @@ module.exports = {
}, },
themes: ['@docusaurus/theme-live-codeblock'], themes: ['@docusaurus/theme-live-codeblock'],
plugins: [ 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', '@docusaurus/plugin-ideal-image',
{ {

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@docusaurus/core": "^2.0.0-alpha.56", "@docusaurus/core": "^2.0.0-alpha.56",
"@docusaurus/plugin-ideal-image": "^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/preset-classic": "^2.0.0-alpha.56",
"@docusaurus/theme-live-codeblock": "^2.0.0-alpha.56", "@docusaurus/theme-live-codeblock": "^2.0.0-alpha.56",
"clsx": "^1.1.1", "clsx": "^1.1.1",

View file

@ -1156,6 +1156,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" 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": "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6" version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@ -3135,6 +3142,11 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@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": "@webassemblyjs/ast@1.8.5":
version "1.8.5" version "1.8.5"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" 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" inherits "^2.0.3"
readable-stream "^2.3.6" 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: follow-redirects@^1.0.0:
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
@ -10720,6 +10737,11 @@ locate-path@^5.0.0:
dependencies: dependencies:
p-locate "^4.1.0" 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: lodash._reinterpolate@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" 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" object-assign "^4.1.1"
react-is "^16.8.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: property-information@^5.0.0, property-information@^5.3.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.3.0.tgz#bc87ac82dc4e72a31bb62040544b1bf9653da039" 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" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== 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: table@^5.2.3, table@^5.4.6:
version "5.4.6" version "5.4.6"
resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" 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" resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.6.tgz#25b0866483a9722474895559088b436fd11f861b"
integrity sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ== 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: tough-cookie@^2.3.3, tough-cookie@~2.5.0:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" 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" buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0" 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: zepto@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/zepto/-/zepto-1.2.0.tgz#e127bd9e66fd846be5eab48c1394882f7c0e4f98" resolved "https://registry.yarnpkg.com/zepto/-/zepto-1.2.0.tgz#e127bd9e66fd846be5eab48c1394882f7c0e4f98"