diff --git a/.eslintignore b/.eslintignore
index 4379870c474f..8a6e126fff9f 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -14,9 +14,11 @@ 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/
+packages/docusaurus-plugin-debug/lib/
packages/docusaurus-plugin-sitemap/lib/
packages/docusaurus-plugin-ideal-image/lib/
packages/docusaurus-plugin-ideal-image/copyUntypedFiles.js
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
new file mode 100644
index 000000000000..3e8d66663309
--- /dev/null
+++ b/.github/workflows/e2e-test.yml
@@ -0,0 +1,32 @@
+name: E2E Test
+
+on:
+ # Trigger the workflow on push or pull request,
+ # but only for the master branch
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: [10.x]
+ steps:
+ - uses: actions/checkout@v1
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Installation
+ run: yarn
+ - name: Setup test-website project against master release
+ run: yarn test:build:v2
+ - name: Build test-website project
+ run: cd test-website && yarn build
+ env:
+ CI: true
diff --git a/.gitignore b/.gitignore
index 913cfe979b6e..c1a3bf0b1d92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,8 +18,10 @@ 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/
+packages/docusaurus-plugin-debug/lib/
packages/docusaurus-plugin-sitemap/lib/
packages/docusaurus-plugin-ideal-image/lib/
diff --git a/.prettierignore b/.prettierignore
index 06637452dbe4..1f599c7c1a41 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -6,10 +6,12 @@ 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/
packages/docusaurus-plugin-content-pages/lib/
+packages/docusaurus-plugin-debug/lib/
packages/docusaurus-plugin-sitemap/lib/
packages/docusaurus-plugin-ideal-image/lib/
__fixtures__
diff --git a/packages/docusaurus-init/templates/bootstrap/babel.config.js b/packages/docusaurus-init/templates/bootstrap/babel.config.js
new file mode 100644
index 000000000000..e00595dae7d6
--- /dev/null
+++ b/packages/docusaurus-init/templates/bootstrap/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
+};
diff --git a/packages/docusaurus-init/templates/classic/babel.config.js b/packages/docusaurus-init/templates/classic/babel.config.js
new file mode 100644
index 000000000000..e00595dae7d6
--- /dev/null
+++ b/packages/docusaurus-init/templates/classic/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
+};
diff --git a/packages/docusaurus-theme-bootstrap/src/theme/ThemeContext.js b/packages/docusaurus-init/templates/facebook/babel.config.js
similarity index 51%
rename from packages/docusaurus-theme-bootstrap/src/theme/ThemeContext.js
rename to packages/docusaurus-init/templates/facebook/babel.config.js
index b521d46bfc3d..81604ce8ec2e 100644
--- a/packages/docusaurus-theme-bootstrap/src/theme/ThemeContext.js
+++ b/packages/docusaurus-init/templates/facebook/babel.config.js
@@ -3,14 +3,10 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
+ *
+ * @format
*/
-import React from 'react';
-
-const ThemeContext = React.createContext({
- isDarkTheme: false,
- setLightTheme: () => {},
- setDarkTheme: () => {},
-});
-
-export default ThemeContext;
+module.exports = {
+ presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
+};
diff --git a/packages/docusaurus-plugin-client-redirects/package.json b/packages/docusaurus-plugin-client-redirects/package.json
new file mode 100644
index 000000000000..899306509d0b
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/package.json
@@ -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"
+ }
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap
new file mode 100644
index 000000000000..d3882b447dbb
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/collectRedirects.test.ts.snap
@@ -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
+"
+`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/createRedirectPageContent.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/createRedirectPageContent.test.ts.snap
new file mode 100644
index 000000000000..7fa3afa57de0
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/createRedirectPageContent.test.ts.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createRedirectPageContent should encode uri special chars 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`createRedirectPageContent should match snapshot 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/normalizePluginOptions.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/normalizePluginOptions.test.ts.snap
new file mode 100644
index 000000000000..76a634cbfdf8
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/normalizePluginOptions.test.ts.snap
@@ -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
+ ]
+}"
+`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap
new file mode 100644
index 000000000000..f4e9039ca0ea
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/redirectValidation.test.ts.snap
@@ -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"`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap
new file mode 100644
index 000000000000..df8a037778a6
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/__snapshots__/writeRedirectFiles.test.ts.snap
@@ -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 [
+ "
+
+
+
+
+
+
+
+",
+]
+`;
+
+exports[`toRedirectFilesMetadata should create appropriate metadatas for root baseUrl: fileContent baseUrl=/ 1`] = `
+Array [
+ "
+
+
+
+
+
+
+
+",
+]
+`;
+
+exports[`toRedirectFilesMetadata should create appropriate metadatas: fileContent 1`] = `
+Array [
+ "
+
+
+
+
+
+
+
+",
+ "
+
+
+
+
+
+
+
+",
+ "
+
+
+
+
+
+
+
+",
+]
+`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts
new file mode 100644
index 000000000000..014aa7c10b82
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/collectRedirects.test.ts
@@ -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',
+ },
+ ]);
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/createRedirectPageContent.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/createRedirectPageContent.test.ts
new file mode 100644
index 000000000000..b9aea1ef3426
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/createRedirectPageContent.test.ts
@@ -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();
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/extensionRedirects.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/extensionRedirects.test.ts
new file mode 100644
index 000000000000..7d90be0b63f0
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/extensionRedirects.test.ts
@@ -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([]);
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts
new file mode 100644
index 000000000000..b95bea1f2321
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/normalizePluginOptions.test.ts
@@ -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();
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts
new file mode 100644
index 000000000000..7ba9973ce182
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/redirectValidation.test.ts
@@ -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();
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts b/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts
new file mode 100644
index 000000000000..fb65ae51a031
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/__tests__/writeRedirectFiles.test.ts
@@ -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`,
+ );
+ });
+});
diff --git a/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts
new file mode 100644
index 000000000000..c9f9f38859ef
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/collectRedirects.ts
@@ -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));
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts b/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts
new file mode 100644
index 000000000000..2275d66d8e48
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/createRedirectPageContent.ts
@@ -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),
+ });
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/extensionRedirects.ts b/packages/docusaurus-plugin-client-redirects/src/extensionRedirects.ts
new file mode 100644
index 000000000000..df4a7110f2e0
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/extensionRedirects.ts
@@ -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));
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/index.ts b/packages/docusaurus-plugin-client-redirects/src/index.ts
new file mode 100644
index 000000000000..c94b734514e4
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/index.ts
@@ -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 {
+ 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);
+ },
+ };
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts b/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts
new file mode 100644
index 000000000000..3a9e9342c0cf
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/normalizePluginOptions.ts
@@ -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({
+ to: PathnameValidator.required(),
+ // See https://stackoverflow.com/a/62177080/82609
+ from: Yup.lazy((from) => {
+ return Array.isArray(from)
+ ? Yup.array().of(PathnameValidator.required()).required()
+ : PathnameValidator.required();
+ }),
+});
+
+const UserOptionsSchema = Yup.object().shape({
+ 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};
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts
new file mode 100644
index 000000000000..c300ff2d6ae0
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/redirectValidation.ts
@@ -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({
+ 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}`,
+ );
+ }
+}
diff --git a/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts b/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts
new file mode 100644
index 000000000000..0ac3b4920d11
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/templates/redirectPage.template.html.ts
@@ -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 `
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/docusaurus-plugin-client-redirects/src/types.ts b/packages/docusaurus-plugin-client-redirects/src/types.ts
new file mode 100644
index 000000000000..fe8b0296ed03
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/types.ts
@@ -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;
+
+// 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
+};
diff --git a/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts b/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts
new file mode 100644
index 000000000000..d8b41a3de964
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/src/writeRedirectFiles.ts
@@ -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;
+
+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));
+}
diff --git a/packages/docusaurus-plugin-client-redirects/tsconfig.json b/packages/docusaurus-plugin-client-redirects/tsconfig.json
new file mode 100644
index 000000000000..f5902ba1089b
--- /dev/null
+++ b/packages/docusaurus-plugin-client-redirects/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "incremental": true,
+ "tsBuildInfoFile": "./lib/.tsbuildinfo",
+ "rootDir": "src",
+ "outDir": "lib"
+ }
+}
diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts
index 6f17ddd99d56..195cb8d12e99 100644
--- a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts
+++ b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts
@@ -164,6 +164,7 @@ describe('simple website', () => {
source: path.join('@site', pluginPath, 'hello.md'),
title: 'Hello, World !',
description: 'Hi, Endilie here :)',
+ latestVersionMainDocPermalink: undefined,
});
expect(docsMetadata['foo/bar']).toEqual({
@@ -178,6 +179,7 @@ describe('simple website', () => {
source: path.join('@site', pluginPath, 'foo', 'bar.md'),
title: 'Bar',
description: 'This is custom description',
+ latestVersionMainDocPermalink: undefined,
});
expect(docsSidebars).toMatchSnapshot();
@@ -340,6 +342,7 @@ describe('versioned website', () => {
title: 'bar',
permalink: '/docs/foo/bar',
},
+ latestVersionMainDocPermalink: undefined,
});
expect(docsMetadata['version-1.0.0/foo/baz']).toEqual({
id: 'version-1.0.0/foo/baz',
diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts
index e020203b8cdc..9b4950b3d1cf 100644
--- a/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts
+++ b/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts
@@ -51,6 +51,7 @@ describe('simple site', () => {
source: path.join('@site', routeBasePath, sourceA),
title: 'Bar',
description: 'This is custom description',
+ latestVersionMainDocPermalink: undefined,
});
expect(dataB).toEqual({
id: 'hello',
@@ -59,6 +60,7 @@ describe('simple site', () => {
source: path.join('@site', routeBasePath, sourceB),
title: 'Hello, World !',
description: `Hi, Endilie here :)`,
+ latestVersionMainDocPermalink: undefined,
});
});
@@ -138,6 +140,7 @@ describe('simple site', () => {
editUrl:
'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md',
description: 'Images',
+ latestVersionMainDocPermalink: undefined,
});
});
@@ -163,6 +166,7 @@ describe('simple site', () => {
title: 'lorem',
editUrl: 'https://github.com/customUrl/docs/lorem.md',
description: 'Lorem ipsum.',
+ latestVersionMainDocPermalink: undefined,
});
// unrelated frontmatter is not part of metadata
@@ -195,6 +199,7 @@ describe('simple site', () => {
description: 'Lorem ipsum.',
lastUpdatedAt: 1539502055,
lastUpdatedBy: 'Author',
+ latestVersionMainDocPermalink: undefined,
});
});
@@ -224,6 +229,7 @@ describe('simple site', () => {
description: 'Lorem ipsum.',
lastUpdatedAt: 1539502055,
lastUpdatedBy: 'Author',
+ latestVersionMainDocPermalink: undefined,
});
});
diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts
index 81d56718b4bf..6740c9e40715 100644
--- a/packages/docusaurus-plugin-content-docs/src/index.ts
+++ b/packages/docusaurus-plugin-content-docs/src/index.ts
@@ -65,6 +65,22 @@ const DEFAULT_OPTIONS: PluginOptions = {
admonitions: {},
};
+function getFirstDocLinkOfSidebar(
+ sidebarItems: DocsSidebarItem[],
+): string | null {
+ for (let sidebarItem of sidebarItems) {
+ if (sidebarItem.type === 'category') {
+ const url = getFirstDocLinkOfSidebar(sidebarItem.items);
+ if (url) {
+ return url;
+ }
+ } else {
+ return sidebarItem.href;
+ }
+ }
+ return null;
+}
+
export default function pluginContentDocs(
context: LoadContext,
opts: Partial,
@@ -302,7 +318,6 @@ Available document ids=
},
{},
);
-
return {
docsMetadata,
docsDir,
@@ -406,6 +421,22 @@ Available document ids=
Object.values(content.docsMetadata),
'version',
);
+ const rootUrl =
+ options.homePageId && content.docsMetadata[options.homePageId]
+ ? normalizeUrl([baseUrl, routeBasePath])
+ : getFirstDocLinkOfSidebar(
+ content.docsSidebars[
+ `version-${versioning.latestVersion}/docs`
+ ],
+ );
+ if (!rootUrl) {
+ throw new Error('Bad sidebars file. No document linked');
+ }
+ Object.values(content.docsMetadata).forEach((docMetadata) => {
+ if (docMetadata.version !== versioning.latestVersion) {
+ docMetadata.latestVersionMainDocPermalink = rootUrl;
+ }
+ });
await Promise.all(
Object.keys(docsMetadataByVersion).map(async (version) => {
const routes: RouteConfig[] = await genRoutes(
diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts
index 82f99cc3f932..1f37c318bcba 100644
--- a/packages/docusaurus-plugin-content-docs/src/types.ts
+++ b/packages/docusaurus-plugin-content-docs/src/types.ts
@@ -119,6 +119,7 @@ export interface MetadataRaw extends LastUpdateData {
sidebar_label?: string;
editUrl?: string;
version?: string;
+ latestVersionMainDocPermalink?: string;
}
export interface Paginator {
diff --git a/packages/docusaurus-plugin-debug/package.json b/packages/docusaurus-plugin-debug/package.json
new file mode 100644
index 000000000000..c93233c10479
--- /dev/null
+++ b/packages/docusaurus-plugin-debug/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@docusaurus/plugin-debug",
+ "version": "2.0.0-alpha.56",
+ "description": "Debug 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"
+ },
+ "peerDependencies": {
+ "@docusaurus/core": "^2.0.0",
+ "react": "^16.8.4",
+ "react-dom": "^16.8.4"
+ },
+ "engines": {
+ "node": ">=10.15.1"
+ }
+}
diff --git a/packages/docusaurus-plugin-debug/src/index.ts b/packages/docusaurus-plugin-debug/src/index.ts
new file mode 100644
index 000000000000..8eadee856646
--- /dev/null
+++ b/packages/docusaurus-plugin-debug/src/index.ts
@@ -0,0 +1,31 @@
+/**
+ * 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} from '@docusaurus/types';
+import {normalizeUrl} from '@docusaurus/utils';
+
+import path from 'path';
+
+export default function pluginContentPages({
+ siteConfig: {baseUrl},
+}: LoadContext): Plugin {
+ return {
+ name: 'docusaurus-plugin-debug',
+
+ getThemePath() {
+ return path.resolve(__dirname, '../src/theme');
+ },
+
+ contentLoaded({actions: {addRoute}}) {
+ addRoute({
+ path: normalizeUrl([baseUrl, '__docusaurus/debug']),
+ component: '@theme/Debug',
+ exact: true,
+ });
+ },
+ };
+}
diff --git a/packages/docusaurus-plugin-debug/src/theme/Debug/index.js b/packages/docusaurus-plugin-debug/src/theme/Debug/index.js
new file mode 100644
index 000000000000..74446ae2e5f3
--- /dev/null
+++ b/packages/docusaurus-plugin-debug/src/theme/Debug/index.js
@@ -0,0 +1,47 @@
+/**
+ * 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 React from 'react';
+import Layout from '@theme/Layout';
+
+import registry from '@generated/registry';
+import routes from '@generated/routes';
+
+import styles from './styles.module.css';
+
+function Debug() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default Debug;
diff --git a/packages/docusaurus-theme-bootstrap/src/theme/hooks/useThemeContext.js b/packages/docusaurus-plugin-debug/src/theme/Debug/styles.module.css
similarity index 51%
rename from packages/docusaurus-theme-bootstrap/src/theme/hooks/useThemeContext.js
rename to packages/docusaurus-plugin-debug/src/theme/Debug/styles.module.css
index a71392756fa0..cb621a15abf8 100644
--- a/packages/docusaurus-theme-bootstrap/src/theme/hooks/useThemeContext.js
+++ b/packages/docusaurus-plugin-debug/src/theme/Debug/styles.module.css
@@ -5,12 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import {useContext} from 'react';
-
-import ThemeContext from '@theme/ThemeContext';
-
-function useThemeContext() {
- return useContext(ThemeContext);
+.Container {
+ display: flex;
+ margin: 1em;
}
-
-export default useThemeContext;
diff --git a/packages/docusaurus-plugin-debug/tsconfig.json b/packages/docusaurus-plugin-debug/tsconfig.json
new file mode 100644
index 000000000000..f5902ba1089b
--- /dev/null
+++ b/packages/docusaurus-plugin-debug/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "incremental": true,
+ "tsBuildInfoFile": "./lib/.tsbuildinfo",
+ "rootDir": "src",
+ "outDir": "lib"
+ }
+}
diff --git a/packages/docusaurus-preset-classic/src/index.js b/packages/docusaurus-preset-classic/src/index.js
index 2a4d489bcc76..894fe315c251 100644
--- a/packages/docusaurus-preset-classic/src/index.js
+++ b/packages/docusaurus-preset-classic/src/index.js
@@ -24,6 +24,7 @@ module.exports = function preset(context, opts = {}) {
isProd &&
googleAnalytics &&
require.resolve('@docusaurus/plugin-google-analytics'),
+ !isProd && require.resolve('@docusaurus/plugin-debug'),
isProd && gtag && require.resolve('@docusaurus/plugin-google-gtag'),
isProd && [require.resolve('@docusaurus/plugin-sitemap'), opts.sitemap],
],
diff --git a/packages/docusaurus-theme-bootstrap/src/theme/DocPaginator/index.js b/packages/docusaurus-theme-bootstrap/src/theme/DocPaginator/index.js
index dbe3011a7b47..39e0e82901f5 100644
--- a/packages/docusaurus-theme-bootstrap/src/theme/DocPaginator/index.js
+++ b/packages/docusaurus-theme-bootstrap/src/theme/DocPaginator/index.js
@@ -12,7 +12,7 @@ function DocPaginator(props) {
const {previous, next} = props.metadata;
return (
-