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 ( + +
+
+

Registry

+
    + {Object.values(registry).map(([, aliasedPath, resolved]) => ( +
  • +
    Aliased Path: {aliasedPath}
    +
    Resolved Path: {resolved}
    +
  • + ))} +
+
+
+

Routes

+
    + {routes.map(({path, exact}) => ( +
  • +
    Route: {path}
    +
    Is exact: {String(Boolean(exact))}
    +
  • + ))} +
+
+
+
+ ); +} + +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 ( -