From 39b66d82ef2f6aacdc9cd91c50c8155717cb3bd1 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Fri, 25 Feb 2022 21:13:15 +0800 Subject: [PATCH] feat(core): rework swizzle CLI (#6243) Co-authored-by: sebastienlorber --- .eslintignore | 2 + .eslintrc.js | 6 +- .github/workflows/tests-swizzle.yml | 37 ++ .github/workflows/tests-windows.yml | 7 +- .gitignore | 2 + .prettierignore | 3 + .stylelintignore | 1 + jest.config.mjs | 1 + package.json | 4 +- packages/docusaurus-logger/src/index.ts | 5 + .../src/index.d.ts | 1 + packages/docusaurus-plugin-debug/src/index.ts | 1 - packages/docusaurus-plugin-pwa/src/index.ts | 5 +- .../src/getSwizzleConfig.ts | 101 +++++ .../docusaurus-theme-classic/src/index.ts | 18 +- .../src/index.ts | 5 +- packages/docusaurus-types/src/index.d.ts | 19 +- packages/docusaurus/bin/docusaurus.mjs | 30 +- packages/docusaurus/package.json | 4 +- packages/docusaurus/src/commands/swizzle.ts | 299 ------------- .../ComponentInSubFolder/index.css | 3 + .../ComponentInSubFolder/index.stories.tsx | 2 + .../ComponentInSubFolder/index.test.tsx | 2 + .../ComponentInSubFolder/index.tsx | 5 + .../ComponentInSubFolder/styles.css | 3 + .../ComponentInSubFolder/styles.module.css | 3 + .../theme/ComponentInFolder/Sibling.css | 3 + .../ComponentInFolder/Sibling.stories.jsx | 2 + .../theme/ComponentInFolder/Sibling.test.js | 2 + .../theme/ComponentInFolder/Sibling.tsx | 5 + .../__fixtures__/FileInTest.ts | 1 + .../__tests__/FileInFixtures.ts | 1 + .../theme/ComponentInFolder/index.css | 3 + .../theme/ComponentInFolder/index.stories.tsx | 2 + .../theme/ComponentInFolder/index.test.tsx | 2 + .../theme/ComponentInFolder/index.tsx | 5 + .../theme/FirstLevelComponent.css | 3 + .../theme/FirstLevelComponent.stories.tsx | 2 + .../theme/FirstLevelComponent.test.tsx | 2 + .../theme/FirstLevelComponent.tsx | 5 + .../__fixtures__/theme/JustAStory.stories.tsx | 2 + .../__fixtures__/theme/JustATest.test.ts | 2 + .../ComponentInFixturesFolder.tsx | 5 + .../__mocks__/ComponentInMocksFolder.tsx | 5 + .../theme/__tests__/ComponentInTestFolder.tsx | 5 + .../__snapshots__/index.test.ts.snap | 423 ++++++++++++++++++ .../swizzle/__tests__/actions.test.ts | 286 ++++++++++++ .../swizzle/__tests__/components.test.ts | 204 +++++++++ .../commands/swizzle/__tests__/config.test.ts | 131 ++++++ .../commands/swizzle/__tests__/index.test.ts | 297 ++++++++++++ .../commands/swizzle/__tests__/testUtils.ts | 23 + .../src/commands/swizzle/actions.ts | 151 +++++++ .../docusaurus/src/commands/swizzle/common.ts | 98 ++++ .../src/commands/swizzle/components.ts | 264 +++++++++++ .../docusaurus/src/commands/swizzle/config.ts | 113 +++++ .../src/commands/swizzle/context.ts | 37 ++ .../docusaurus/src/commands/swizzle/index.ts | 159 +++++++ .../src/commands/swizzle/prompts.ts | 127 ++++++ .../docusaurus/src/commands/swizzle/tables.ts | 140 ++++++ .../docusaurus/src/commands/swizzle/themes.ts | 149 ++++++ .../docusaurus/src/server/plugins/init.ts | 25 +- project-words.txt | 1 + website/_dogfooding/dogfooding.config.js | 14 + .../_dogfooding/testSwizzleThemeClassic.mjs | 157 +++++++ website/docs/_partials/swizzleWarning.mdx | 5 - website/docs/advanced/client.md | 77 ++++ website/docs/advanced/swizzling.md | 195 -------- website/docs/cli.md | 37 +- website/docs/migration/migration-manual.md | 2 +- website/docs/styling-layout.md | 14 +- website/docs/swizzling.md | 324 ++++++++++++++ website/docs/using-plugins.md | 2 +- website/docusaurus.config.js | 7 +- website/package.json | 10 +- website/sidebars.js | 3 +- website/src/plugins/changelog/syncAvatars.js | 1 - website/testCSSOrder.mjs | 1 - yarn.lock | 115 ++++- 78 files changed, 3633 insertions(+), 585 deletions(-) create mode 100644 .github/workflows/tests-swizzle.yml create mode 100644 packages/docusaurus-theme-classic/src/getSwizzleConfig.ts delete mode 100644 packages/docusaurus/src/commands/swizzle.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.stories.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.test.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.module.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.stories.jsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.test.js create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__fixtures__/FileInTest.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__tests__/FileInFixtures.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.stories.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.test.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.css create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.stories.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.test.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustAStory.stories.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustATest.test.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__fixtures__/ComponentInFixturesFolder.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__mocks__/ComponentInMocksFolder.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__tests__/ComponentInTestFolder.tsx create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/actions.test.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/components.test.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/config.test.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts create mode 100644 packages/docusaurus/src/commands/swizzle/__tests__/testUtils.ts create mode 100644 packages/docusaurus/src/commands/swizzle/actions.ts create mode 100644 packages/docusaurus/src/commands/swizzle/common.ts create mode 100644 packages/docusaurus/src/commands/swizzle/components.ts create mode 100644 packages/docusaurus/src/commands/swizzle/config.ts create mode 100644 packages/docusaurus/src/commands/swizzle/context.ts create mode 100644 packages/docusaurus/src/commands/swizzle/index.ts create mode 100644 packages/docusaurus/src/commands/swizzle/prompts.ts create mode 100644 packages/docusaurus/src/commands/swizzle/tables.ts create mode 100644 packages/docusaurus/src/commands/swizzle/themes.ts create mode 100644 website/_dogfooding/testSwizzleThemeClassic.mjs delete mode 100644 website/docs/_partials/swizzleWarning.mdx create mode 100644 website/docs/advanced/client.md delete mode 100644 website/docs/advanced/swizzling.md create mode 100644 website/docs/swizzling.md diff --git a/.eslintignore b/.eslintignore index a82d6bdc1b..d870ab9730 100644 --- a/.eslintignore +++ b/.eslintignore @@ -17,3 +17,5 @@ copyUntypedFiles.mjs packages/create-docusaurus/lib/* packages/create-docusaurus/templates/facebook/.eslintrc.js + +website/_dogfooding/_swizzle_theme_tests diff --git a/.eslintrc.js b/.eslintrc.js index d3c4c9159d..408eda9737 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -260,7 +260,11 @@ module.exports = { 'no-unused-vars': OFF, '@typescript-eslint/no-unused-vars': [ ERROR, - {argsIgnorePattern: '^_', ignoreRestSiblings: true}, + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, ], }, overrides: [ diff --git a/.github/workflows/tests-swizzle.yml b/.github/workflows/tests-swizzle.yml new file mode 100644 index 0000000000..7fde90ee5b --- /dev/null +++ b/.github/workflows/tests-swizzle.yml @@ -0,0 +1,37 @@ +name: Swizzle Tests + +on: + pull_request: + branches: + - main + paths: + - packages/** + +jobs: + test: + name: Swizzle + timeout-minutes: 30 + runs-on: ubuntu-latest + strategy: + matrix: + action: ['eject', 'wrap'] + variant: ['js', 'ts'] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: 14 + cache: yarn + - name: Installation + run: yarn + + # Swizzle all the theme components + - name: Swizzle (${{matrix.action}} - ${{matrix.variant}}) + run: yarn workspace website test:swizzle:${{matrix.action}}:${{matrix.variant}} + # Build swizzled site + - name: Build website + run: yarn build:website:fast + # Ensure swizzled site still typechecks + - name: TypeCheck website + run: yarn workspace website typecheck diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 6dc7265432..b705c0c868 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -34,5 +34,10 @@ jobs: mkdir -p "website/_dogfooding/_pages tests/deep-file-path-test/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar/foo/bar" cd "$_" echo "# hello" > test-file.md + # Lightweight version of tests-swizzle.yml workflow, but for Windows + - name: Swizzle Wrap TS + run: yarn workspace website test:swizzle:wrap:ts - name: Docusaurus Build - run: yarn build:website --locale en + run: yarn build:website:fast + - name: TypeCheck website + run: yarn workspace website typecheck diff --git a/.gitignore b/.gitignore index e923cdb4c2..29b717df7f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ website/changelog !website/netlifyDeployPreview/index.html !website/netlifyDeployPreview/_redirects +website/_dogfooding/_swizzle_theme_tests + website/i18n/**/* #!website/i18n/fr #!website/i18n/fr/**/* diff --git a/.prettierignore b/.prettierignore index 64fe52d60e..98de3b0f4a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,3 +20,6 @@ website/versioned_sidebars/*.json examples/ website/static/katex/katex.min.css + +website/changelog/_swizzle_theme_tests +website/_dogfooding/_swizzle_theme_tests diff --git a/.stylelintignore b/.stylelintignore index 6b2d203d0d..951af3a16e 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -2,6 +2,7 @@ * !*/ !*.css +__tests__/ build coverage examples/ diff --git a/jest.config.mjs b/jest.config.mjs index fa508a5fcb..7e4a4dcc97 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -10,6 +10,7 @@ import {fileURLToPath} from 'url'; const ignorePatterns = [ '/node_modules/', '__fixtures__', + '/testUtils.ts', '/packages/docusaurus/lib', '/packages/docusaurus-utils/lib', '/packages/docusaurus-utils-validation/lib', diff --git a/package.json b/package.json index 7329d97e78..1984d770e5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "build:website": "yarn workspace website build", "build:website:baseUrl": "yarn workspace website build:baseUrl", "build:website:blogOnly": "yarn workspace website build:blogOnly", - "build:website:deployPreview": "cross-env NETLIFY=true CONTEXT='deploy-preview' yarn workspace website build", + "build:website:deployPreview:testWrap": "yarn workspace website test:swizzle:wrap:ts", + "build:website:deployPreview:build": "cross-env NETLIFY=true CONTEXT='deploy-preview' yarn workspace website build", + "build:website:deployPreview": "yarn build:website:deployPreview:testWrap && yarn build:website:deployPreview:build", "build:website:fast": "yarn workspace website build:fast", "build:website:en": "yarn workspace website build --locale en", "clear:website": "yarn workspace website clear", diff --git a/packages/docusaurus-logger/src/index.ts b/packages/docusaurus-logger/src/index.ts index c9e2c27f58..6937ddde6d 100644 --- a/packages/docusaurus-logger/src/index.ts +++ b/packages/docusaurus-logger/src/index.ts @@ -120,6 +120,10 @@ function success(msg: unknown, ...values: InterpolatableValue[]): void { ); } +function newLine(): void { + console.log(); +} + const logger = { red: chalk.red, yellow: chalk.yellow, @@ -136,6 +140,7 @@ const logger = { warn, error, success, + newLine, }; // TODO remove when migrating to ESM diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index bcb5ae96c3..80291f92f4 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -83,6 +83,7 @@ declare module '@generated/codeTranslations' { } declare module '@theme-original/*'; +declare module '@theme-init/*'; declare module '@theme/Error' { export interface Props { diff --git a/packages/docusaurus-plugin-debug/src/index.ts b/packages/docusaurus-plugin-debug/src/index.ts index f85f3ba772..0094918b4c 100644 --- a/packages/docusaurus-plugin-debug/src/index.ts +++ b/packages/docusaurus-plugin-debug/src/index.ts @@ -26,7 +26,6 @@ export default function pluginDebug({ getThemePath() { return path.resolve(__dirname, '../lib/theme'); }, - getTypeScriptThemePath() { return path.resolve(__dirname, '../src/theme'); }, diff --git a/packages/docusaurus-plugin-pwa/src/index.ts b/packages/docusaurus-plugin-pwa/src/index.ts index 8696d3dc0d..e099ec0411 100644 --- a/packages/docusaurus-plugin-pwa/src/index.ts +++ b/packages/docusaurus-plugin-pwa/src/index.ts @@ -64,7 +64,10 @@ export default function pluginPWA( name: 'docusaurus-plugin-pwa', getThemePath() { - return path.resolve(__dirname, './theme'); + return path.resolve(__dirname, '../lib/theme'); + }, + getTypeScriptThemePath() { + return path.resolve(__dirname, '../src/theme'); }, getClientModules() { diff --git a/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts b/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts new file mode 100644 index 0000000000..465d078b7f --- /dev/null +++ b/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts @@ -0,0 +1,101 @@ +/** + * 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 type {SwizzleConfig} from '@docusaurus/types'; + +export default function getSwizzleConfig(): SwizzleConfig { + return { + components: { + CodeBlock: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + description: + 'The component used to render multi-line code blocks, generally used in Markdown files.', + }, + DocSidebar: { + actions: { + wrap: 'safe', + eject: 'unsafe', // too much technical code in sidebar, not very safe atm + }, + description: 'The sidebar component on docs pages', + }, + Footer: { + actions: { + wrap: 'safe', + eject: 'unsafe', // TODO split footer into smaller parts + }, + description: "The footer component of you site's layout", + }, + NotFound: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + description: + 'The global 404 page of your site, meant to be ejected and customized', + }, + SearchBar: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + // TODO how to describe this one properly? + // By default it's an empty placeholder for the user to fill + description: + 'The search bar component of your site, appearing in the navbar.', + }, + IconArrow: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + description: 'The arrow icon component', + }, + IconEdit: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + description: 'The edit icon component', + }, + IconMenu: { + actions: { + wrap: 'safe', + eject: 'safe', + }, + description: 'The menu icon component', + }, + + 'prism-include-languages': { + actions: { + wrap: 'forbidden', // not a component! + eject: 'safe', + }, + description: + 'The Prism languages to include for code block syntax highlighting. Meant to be ejected.', + }, + MDXComponents: { + actions: { + wrap: 'forbidden', /// TODO allow wrapping objects??? + eject: 'safe', + }, + description: + 'The MDX components to use for rendering MDX files. Meant to be ejected.', + }, + + // TODO should probably not even appear here + 'NavbarItem/utils': { + actions: { + wrap: 'forbidden', + eject: 'forbidden', + }, + }, + }, + }; +} diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 8434e3b0ff..6bccc8f88e 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -207,21 +207,5 @@ ${announcementBar ? AnnouncementBarInlineJavaScript : ''} }; } -const swizzleAllowedComponents = [ - 'CodeBlock', - 'DocSidebar', - 'Footer', - 'NotFound', - 'SearchBar', - 'IconArrow', - 'IconEdit', - 'IconMenu', - 'hooks/useTheme', - 'prism-include-languages', -]; - -export function getSwizzleComponentList(): string[] { - return swizzleAllowedComponents; -} - +export {default as getSwizzleConfig} from './getSwizzleConfig'; export {validateThemeConfig} from './validateThemeConfig'; diff --git a/packages/docusaurus-theme-search-algolia/src/index.ts b/packages/docusaurus-theme-search-algolia/src/index.ts index 007fc9b361..6c41d564f8 100644 --- a/packages/docusaurus-theme-search-algolia/src/index.ts +++ b/packages/docusaurus-theme-search-algolia/src/index.ts @@ -47,11 +47,10 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin { name: 'docusaurus-theme-search-algolia', getThemePath() { - return path.resolve(__dirname, './theme'); + return path.resolve(__dirname, '../lib/theme'); }, - getTypeScriptThemePath() { - return path.resolve(__dirname, '..', 'src', 'theme'); + return path.resolve(__dirname, '../src/theme'); }, getDefaultCodeTranslationMessages() { diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index f664c66c69..9b6ef14bc3 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -316,13 +316,30 @@ export type LoadedPlugin = InitializedPlugin & { readonly content: Content; }; +export type SwizzleAction = 'eject' | 'wrap'; +export type SwizzleActionStatus = 'safe' | 'unsafe' | 'forbidden'; + +export type SwizzleComponentConfig = { + actions: Record; + description?: string; +}; + +export type SwizzleConfig = { + components: Record; + // Other settings could be added here, + // For example: the ability to declare the config as exhaustive + // so that we can emit errors +}; + export type PluginModule = { (context: LoadContext, options: Options): | Plugin | Promise>; validateOptions?: (data: OptionValidationContext) => T; validateThemeConfig?: (data: ThemeConfigValidationContext) => T; - getSwizzleComponentList?: () => string[]; + + getSwizzleComponentList?: () => string[] | undefined; // TODO deprecate this one later + getSwizzleConfig?: () => SwizzleConfig | undefined; }; export type ImportedPluginModule = PluginModule & { diff --git a/packages/docusaurus/bin/docusaurus.mjs b/packages/docusaurus/bin/docusaurus.mjs index 410f95f249..168d421118 100755 --- a/packages/docusaurus/bin/docusaurus.mjs +++ b/packages/docusaurus/bin/docusaurus.mjs @@ -68,20 +68,28 @@ cli cli .command('swizzle [themeName] [componentName] [siteDir]') - .description('Copy the theme files into website folder for customization.') + .description( + 'Wraps or ejects the original theme files into website folder for customization.', + ) .option( - '--typescript', + '-w, --wrap', + 'Creates a wrapper around the original theme component.\nAllows rendering other components before/after the original theme component.', + ) + .option( + '-e, --eject', + 'Ejects the full source code of the original theme component.\nAllows overriding the original component entirely with your own UI and logic.', + ) + .option( + '-l, --list', + 'only list the available themes/components without further prompting (default: false)', + ) + .option( + '-t, --typescript', 'copy TypeScript theme files when possible (default: false)', ) - .option('--danger', 'enable swizzle for internal component of themes') - .action(async (themeName, componentName, siteDir, {typescript, danger}) => { - swizzle( - await resolveDir(siteDir), - themeName, - componentName, - typescript, - danger, - ); + .option('--danger', 'enable swizzle for unsafe component of themes') + .action(async (themeName, componentName, siteDir, options) => { + swizzle(await resolveDir(siteDir), themeName, componentName, options); }); cli diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 31f6045248..277a44ffd1 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -56,6 +56,7 @@ "boxen": "^6.2.1", "chokidar": "^3.5.3", "clean-css": "^5.2.4", + "cli-table3": "^0.6.1", "combine-promises": "^1.1.0", "commander": "^5.1.0", "copy-webpack-plugin": "^10.2.4", @@ -117,7 +118,8 @@ "@types/wait-on": "^5.3.1", "@types/webpack-bundle-analyzer": "^4.4.1", "react-test-renderer": "^17.0.2", - "tmp-promise": "^3.0.3" + "tmp-promise": "^3.0.3", + "tree-node-cli": "^1.5.2" }, "peerDependencies": { "react": "^16.8.4 || ^17.0.0", diff --git a/packages/docusaurus/src/commands/swizzle.ts b/packages/docusaurus/src/commands/swizzle.ts deleted file mode 100644 index 95580cdfd4..0000000000 --- a/packages/docusaurus/src/commands/swizzle.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * 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. - */ - -/* eslint-disable no-restricted-properties */ -import logger from '@docusaurus/logger'; -import fs from 'fs-extra'; -import importFresh from 'import-fresh'; -import path from 'path'; -import type {ImportedPluginModule, PluginConfig} from '@docusaurus/types'; -import leven from 'leven'; -import {partition} from 'lodash'; -import {THEME_PATH} from '@docusaurus/utils'; -import {loadContext, loadPluginConfigs} from '../server'; -import initPlugins from '../server/plugins/init'; -import {normalizePluginOptions} from '@docusaurus/utils-validation'; - -export function getPluginNames(plugins: PluginConfig[]): string[] { - return plugins - .filter( - (plugin) => - typeof plugin === 'string' || - (Array.isArray(plugin) && typeof plugin[0] === 'string'), - ) - .map((plugin) => { - const pluginPath = Array.isArray(plugin) ? plugin[0] : plugin; - if (typeof pluginPath === 'string') { - let packagePath = path.dirname(pluginPath); - while (packagePath) { - if (fs.existsSync(path.join(packagePath, 'package.json'))) { - break; - } else { - packagePath = path.dirname(packagePath); - } - } - if (packagePath === '.') { - return pluginPath; - } - return importFresh<{name: string}>( - path.join(packagePath, 'package.json'), - ).name; - } - - return ''; - }) - .filter((plugin) => plugin !== ''); -} - -const formatComponentName = (componentName: string): string => - componentName - .replace(/[\\/]index\.(?:jsx?|tsx?)/, '') - .replace(/\.(?:jsx?|tsx?)/, ''); - -function readComponent(themePath: string) { - function walk(dir: string): Array { - let results: Array = []; - const list = fs.readdirSync(dir); - list.forEach((file: string) => { - const fullPath = path.join(dir, file); - const stat = fs.statSync(fullPath); - if (stat && stat.isDirectory()) { - results = results.concat(walk(fullPath)); - } else if (!/\.css|\.d\.ts|\.d\.map/.test(fullPath)) { - results.push(fullPath); - } - }); - return results; - } - - return walk(themePath).map((filePath) => - formatComponentName(path.relative(themePath, filePath)), - ); -} - -// load components from theme based on configurations -function getComponentName( - themePath: string, - plugin: ImportedPluginModule, - danger: boolean, -): Array { - // support both commonjs and ES style exports - const getSwizzleComponentList = - plugin.default?.getSwizzleComponentList ?? plugin.getSwizzleComponentList; - if (getSwizzleComponentList) { - const allowedComponent = getSwizzleComponentList(); - if (danger) { - return readComponent(themePath); - } - return allowedComponent; - } - return readComponent(themePath); -} - -function themeComponents( - themePath: string, - plugin: ImportedPluginModule, -): string { - const components = colorCode(themePath, plugin); - - if (components.length === 0) { - return 'No component to swizzle.'; - } - - return `Theme components available for swizzle. - -${logger.green(logger.bold('green =>'))} safe: lower breaking change risk -${logger.red(logger.bold('red =>'))} unsafe: higher breaking change risk - -${components.join('\n')} -`; -} - -function colorCode( - themePath: string, - plugin: ImportedPluginModule, -): Array { - // support both commonjs and ES style exports - const getSwizzleComponentList = - plugin.default?.getSwizzleComponentList ?? plugin.getSwizzleComponentList; - - const components = readComponent(themePath); - const allowedComponent = getSwizzleComponentList - ? getSwizzleComponentList() - : []; - - const [greenComponents, redComponents] = partition(components, (comp) => - allowedComponent.includes(comp), - ); - - return [ - ...greenComponents.map( - (component) => `${logger.green(logger.bold('safe:'))} ${component}`, - ), - ...redComponents.map( - (component) => `${logger.red(logger.bold('unsafe:'))} ${component}`, - ), - ]; -} - -export default async function swizzle( - siteDir: string, - themeName?: string, - componentName?: string, - typescript?: boolean, - danger?: boolean, -): Promise { - const context = await loadContext(siteDir); - const pluginConfigs = await loadPluginConfigs(context); - const pluginNames = getPluginNames(pluginConfigs); - const plugins = await initPlugins({ - pluginConfigs, - context, - }); - const themeNames = pluginNames.filter((_, index) => - typescript - ? plugins[index].getTypeScriptThemePath - : plugins[index].getThemePath, - ); - - if (!themeName) { - logger.info`Themes available for swizzle: name=${themeNames}`; - return; - } - - let pluginModule: ImportedPluginModule; - try { - pluginModule = importFresh(themeName); - } catch { - let suggestion: string | undefined; - themeNames.forEach((name) => { - if (leven(name, themeName) < 4) { - suggestion = name; - } - }); - logger.error`Theme name=${themeName} not found. ${ - suggestion - ? logger.interpolate`Did you mean name=${suggestion}?` - : logger.interpolate`Themes available for swizzle: ${themeNames}` - }`; - process.exit(1); - } - - let pluginOptions = {}; - const resolvedThemeName = require.resolve(themeName); - // find the plugin from list of plugin and get options if specified - pluginConfigs.forEach((pluginConfig) => { - // plugin can be a [string], [string,object] or string. - if (Array.isArray(pluginConfig) && typeof pluginConfig[0] === 'string') { - if (require.resolve(pluginConfig[0]) === resolvedThemeName) { - if (pluginConfig.length === 2) { - const [, options] = pluginConfig; - pluginOptions = options; - } - } - } - }); - - // support both commonjs and ES style exports - const validateOptions = - pluginModule.default?.validateOptions ?? pluginModule.validateOptions; - if (validateOptions) { - pluginOptions = validateOptions({ - validate: normalizePluginOptions, - options: pluginOptions, - }); - } - - // support both commonjs and ES style exports - const plugin = pluginModule.default ?? pluginModule; - const pluginInstance = await plugin(context, pluginOptions); - const themePath = typescript - ? pluginInstance.getTypeScriptThemePath?.() - : pluginInstance.getThemePath?.(); - - if (!themePath) { - logger.warn( - typescript - ? logger.interpolate`name=${themeName} does not provide TypeScript theme code via ${'getTypeScriptThemePath()'}.` - : logger.interpolate`name=${themeName} does not provide any theme code.`, - ); - process.exit(1); - } - - if (!componentName) { - logger.info(themeComponents(themePath, pluginModule)); - return; - } - - const components = getComponentName(themePath, pluginModule, Boolean(danger)); - const formattedComponentName = formatComponentName(componentName); - const isComponentExists = components.find( - (component) => component === formattedComponentName, - ); - let mostSuitableComponent = componentName; - - if (!isComponentExists) { - let mostSuitableMatch = componentName; - let score = formattedComponentName.length; - components.forEach((component) => { - if (component.toLowerCase() === formattedComponentName.toLowerCase()) { - // may be components with same lowercase key, try to match closest - // component - const currentScore = leven(formattedComponentName, component); - if (currentScore < score) { - score = currentScore; - mostSuitableMatch = component; - } - } - }); - - if (mostSuitableMatch !== componentName) { - mostSuitableComponent = mostSuitableMatch; - logger.error`Component name=${componentName} doesn't exist.`; - logger.info`name=${mostSuitableComponent} is swizzled instead of name=${componentName}.`; - } - } - - let fromPath = path.join(themePath, mostSuitableComponent); - let toPath = path.resolve(siteDir, THEME_PATH, mostSuitableComponent); - // Handle single TypeScript/JavaScript file only. - // E.g: if does not exist, we try to swizzle - // .(ts|tsx|js) instead - if (!fs.existsSync(fromPath)) { - if (fs.existsSync(`${fromPath}.ts`)) { - [fromPath, toPath] = [`${fromPath}.ts`, `${toPath}.ts`]; - } else if (fs.existsSync(`${fromPath}.tsx`)) { - [fromPath, toPath] = [`${fromPath}.tsx`, `${toPath}.tsx`]; - } else if (fs.existsSync(`${fromPath}.js`)) { - [fromPath, toPath] = [`${fromPath}.js`, `${toPath}.js`]; - } else { - let suggestion: string | undefined; - components.forEach((name) => { - if (leven(name, mostSuitableComponent) < 3) { - suggestion = name; - } - }); - logger.error`Component name=${mostSuitableComponent} not found. ${ - suggestion - ? logger.interpolate`Did you mean name=${suggestion} ?` - : themeComponents(themePath, pluginModule) - }`; - process.exit(1); - } - } - - if (!components.includes(mostSuitableComponent) && !danger) { - logger.error`name=${mostSuitableComponent} is an internal component and has a higher breaking change probability. If you want to swizzle it, use the code=${'--danger'} flag.`; - process.exit(1); - } - - await fs.copy(fromPath, toPath); - - logger.success`Copied code=${ - mostSuitableComponent ? `${themeName} ${mostSuitableComponent}` : themeName - } to path=${path.relative(process.cwd(), toPath)}.`; -} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.stories.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.stories.tsx new file mode 100644 index 0000000000..607c26aced --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.stories.tsx @@ -0,0 +1,2 @@ +// fake storybook file +export {}; diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.test.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.test.tsx new file mode 100644 index 0000000000..32c19199c8 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.test.tsx @@ -0,0 +1,2 @@ +// fake test file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.tsx new file mode 100644 index 0000000000..6a88f9c00b --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ComponentInSubFolder() { + return
ComponentInSubFolder
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.module.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.module.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/ComponentInSubFolder/styles.module.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.stories.jsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.stories.jsx new file mode 100644 index 0000000000..83cd141de2 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.stories.jsx @@ -0,0 +1,2 @@ +// fake storybook file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.test.js b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.test.js new file mode 100644 index 0000000000..32c19199c8 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.test.js @@ -0,0 +1,2 @@ +// fake test file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.tsx new file mode 100644 index 0000000000..ce1ea507e9 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/Sibling.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function Sibling() { + return
Sibling
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__fixtures__/FileInTest.ts b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__fixtures__/FileInTest.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__fixtures__/FileInTest.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__tests__/FileInFixtures.ts b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__tests__/FileInFixtures.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/__tests__/FileInFixtures.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.stories.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.stories.tsx new file mode 100644 index 0000000000..607c26aced --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.stories.tsx @@ -0,0 +1,2 @@ +// fake storybook file +export {}; diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.test.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.test.tsx new file mode 100644 index 0000000000..32c19199c8 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.test.tsx @@ -0,0 +1,2 @@ +// fake test file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.tsx new file mode 100644 index 0000000000..c8a3a86a5f --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/ComponentInFolder/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ComponentInFolder() { + return
ComponentInFolder
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.css b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.css new file mode 100644 index 0000000000..7aa192f3b3 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.css @@ -0,0 +1,3 @@ +.testClass { + background: black; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.stories.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.stories.tsx new file mode 100644 index 0000000000..83cd141de2 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.stories.tsx @@ -0,0 +1,2 @@ +// fake storybook file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.test.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.test.tsx new file mode 100644 index 0000000000..32c19199c8 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.test.tsx @@ -0,0 +1,2 @@ +// fake test file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.tsx new file mode 100644 index 0000000000..6cd8764e91 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/FirstLevelComponent.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function FirstLevelComponent() { + return
First level component
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustAStory.stories.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustAStory.stories.tsx new file mode 100644 index 0000000000..83cd141de2 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustAStory.stories.tsx @@ -0,0 +1,2 @@ +// fake storybook file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustATest.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustATest.test.ts new file mode 100644 index 0000000000..83cd141de2 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/JustATest.test.ts @@ -0,0 +1,2 @@ +// fake storybook file +export {} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__fixtures__/ComponentInFixturesFolder.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__fixtures__/ComponentInFixturesFolder.tsx new file mode 100644 index 0000000000..eefcdefa68 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__fixtures__/ComponentInFixturesFolder.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ComponentInFixturesFolder() { + return
ComponentInFixturesFolder
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__mocks__/ComponentInMocksFolder.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__mocks__/ComponentInMocksFolder.tsx new file mode 100644 index 0000000000..c6c68e51cb --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__mocks__/ComponentInMocksFolder.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ComponentInMocksFolder() { + return
ComponentInMocksFolder
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__tests__/ComponentInTestFolder.tsx b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__tests__/ComponentInTestFolder.tsx new file mode 100644 index 0000000000..bbc1f2c47b --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__fixtures__/theme/__tests__/ComponentInTestFolder.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ComponentInTestFolder() { + return
ComponentInTestFolder
; +} diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000..faeadf641e --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,423 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`swizzle eject ComponentInFolder JS: ComponentInFolder/Sibling.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder JS: ComponentInFolder/Sibling.tsx 1`] = ` +"import React from 'react'; + +export default function Sibling() { + return
Sibling
; +} +" +`; + +exports[`swizzle eject ComponentInFolder JS: ComponentInFolder/index.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder JS: ComponentInFolder/index.tsx 1`] = ` +"import React from 'react'; + +export default function ComponentInFolder() { + return
ComponentInFolder
; +} +" +`; + +exports[`swizzle eject ComponentInFolder JS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + ├── Sibling.css + ├── Sibling.tsx + ├── index.css + └── index.tsx" +`; + +exports[`swizzle eject ComponentInFolder TS: ComponentInFolder/Sibling.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder TS: ComponentInFolder/Sibling.tsx 1`] = ` +"import React from 'react'; + +export default function Sibling() { + return
Sibling
; +} +" +`; + +exports[`swizzle eject ComponentInFolder TS: ComponentInFolder/index.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder TS: ComponentInFolder/index.tsx 1`] = ` +"import React from 'react'; + +export default function ComponentInFolder() { + return
ComponentInFolder
; +} +" +`; + +exports[`swizzle eject ComponentInFolder TS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + ├── Sibling.css + ├── Sibling.tsx + ├── index.css + └── index.tsx" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder JS: ComponentInFolder/ComponentInSubFolder/index.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder JS: ComponentInFolder/ComponentInSubFolder/index.tsx 1`] = ` +"import React from 'react'; + +export default function ComponentInSubFolder() { + return
ComponentInSubFolder
; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder JS: ComponentInFolder/ComponentInSubFolder/styles.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder JS: ComponentInFolder/ComponentInSubFolder/styles.module.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder JS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── ComponentInSubFolder + ├── index.css + ├── index.tsx + ├── styles.css + └── styles.module.css" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder TS: ComponentInFolder/ComponentInSubFolder/index.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder TS: ComponentInFolder/ComponentInSubFolder/index.tsx 1`] = ` +"import React from 'react'; + +export default function ComponentInSubFolder() { + return
ComponentInSubFolder
; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder TS: ComponentInFolder/ComponentInSubFolder/styles.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder TS: ComponentInFolder/ComponentInSubFolder/styles.module.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/ComponentInSubFolder TS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── ComponentInSubFolder + ├── index.css + ├── index.tsx + ├── styles.css + └── styles.module.css" +`; + +exports[`swizzle eject ComponentInFolder/Sibling JS: Sibling.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/Sibling JS: Sibling.tsx 1`] = ` +"import React from 'react'; + +export default function Sibling() { + return
Sibling
; +} +" +`; + +exports[`swizzle eject ComponentInFolder/Sibling JS: theme dir tree 1`] = ` +"theme +├── Sibling.css +└── Sibling.tsx" +`; + +exports[`swizzle eject ComponentInFolder/Sibling TS: Sibling.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject ComponentInFolder/Sibling TS: Sibling.tsx 1`] = ` +"import React from 'react'; + +export default function Sibling() { + return
Sibling
; +} +" +`; + +exports[`swizzle eject ComponentInFolder/Sibling TS: theme dir tree 1`] = ` +"theme +├── Sibling.css +└── Sibling.tsx" +`; + +exports[`swizzle eject FirstLevelComponent JS: FirstLevelComponent.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject FirstLevelComponent JS: FirstLevelComponent.tsx 1`] = ` +"import React from 'react'; + +export default function FirstLevelComponent() { + return
First level component
; +} +" +`; + +exports[`swizzle eject FirstLevelComponent JS: theme dir tree 1`] = ` +"theme +├── FirstLevelComponent.css +└── FirstLevelComponent.tsx" +`; + +exports[`swizzle eject FirstLevelComponent TS: FirstLevelComponent.css 1`] = ` +".testClass { + background: black; +} +" +`; + +exports[`swizzle eject FirstLevelComponent TS: FirstLevelComponent.tsx 1`] = ` +"import React from 'react'; + +export default function FirstLevelComponent() { + return
First level component
; +} +" +`; + +exports[`swizzle eject FirstLevelComponent TS: theme dir tree 1`] = ` +"theme +├── FirstLevelComponent.css +└── FirstLevelComponent.tsx" +`; + +exports[`swizzle wrap ComponentInFolder JS: ComponentInFolder/index.js 1`] = ` +"import React from 'react'; +import ComponentInFolder from '@theme-original/ComponentInFolder'; + +export default function ComponentInFolderWrapper(props) { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder JS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── index.js" +`; + +exports[`swizzle wrap ComponentInFolder TS: ComponentInFolder/index.tsx 1`] = ` +"import React, {ComponentProps} from 'react'; +import type ComponentInFolderType from '@theme/ComponentInFolder'; +import ComponentInFolder from '@theme-original/ComponentInFolder'; + +type Props = ComponentProps + +export default function ComponentInFolderWrapper(props: Props): JSX.Element { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder TS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── index.tsx" +`; + +exports[`swizzle wrap ComponentInFolder/ComponentInSubFolder JS: ComponentInFolder/ComponentInSubFolder/index.js 1`] = ` +"import React from 'react'; +import ComponentInSubFolder from '@theme-original/ComponentInFolder/ComponentInSubFolder'; + +export default function ComponentInSubFolderWrapper(props) { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder/ComponentInSubFolder JS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── ComponentInSubFolder + └── index.js" +`; + +exports[`swizzle wrap ComponentInFolder/ComponentInSubFolder TS: ComponentInFolder/ComponentInSubFolder/index.tsx 1`] = ` +"import React, {ComponentProps} from 'react'; +import type ComponentInSubFolderType from '@theme/ComponentInFolder/ComponentInSubFolder'; +import ComponentInSubFolder from '@theme-original/ComponentInFolder/ComponentInSubFolder'; + +type Props = ComponentProps + +export default function ComponentInSubFolderWrapper(props: Props): JSX.Element { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder/ComponentInSubFolder TS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── ComponentInSubFolder + └── index.tsx" +`; + +exports[`swizzle wrap ComponentInFolder/Sibling JS: ComponentInFolder/Sibling.js 1`] = ` +"import React from 'react'; +import Sibling from '@theme-original/ComponentInFolder/Sibling'; + +export default function SiblingWrapper(props) { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder/Sibling JS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── Sibling.js" +`; + +exports[`swizzle wrap ComponentInFolder/Sibling TS: ComponentInFolder/Sibling.tsx 1`] = ` +"import React, {ComponentProps} from 'react'; +import type SiblingType from '@theme/ComponentInFolder/Sibling'; +import Sibling from '@theme-original/ComponentInFolder/Sibling'; + +type Props = ComponentProps + +export default function SiblingWrapper(props: Props): JSX.Element { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap ComponentInFolder/Sibling TS: theme dir tree 1`] = ` +"theme +└── ComponentInFolder + └── Sibling.tsx" +`; + +exports[`swizzle wrap FirstLevelComponent JS: FirstLevelComponent.js 1`] = ` +"import React from 'react'; +import FirstLevelComponent from '@theme-original/FirstLevelComponent'; + +export default function FirstLevelComponentWrapper(props) { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap FirstLevelComponent JS: theme dir tree 1`] = ` +"theme +└── FirstLevelComponent.js" +`; + +exports[`swizzle wrap FirstLevelComponent TS: FirstLevelComponent.tsx 1`] = ` +"import React, {ComponentProps} from 'react'; +import type FirstLevelComponentType from '@theme/FirstLevelComponent'; +import FirstLevelComponent from '@theme-original/FirstLevelComponent'; + +type Props = ComponentProps + +export default function FirstLevelComponentWrapper(props: Props): JSX.Element { + return ( + <> + + + ); +} +" +`; + +exports[`swizzle wrap FirstLevelComponent TS: theme dir tree 1`] = ` +"theme +└── FirstLevelComponent.tsx" +`; diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/actions.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/actions.test.ts new file mode 100644 index 0000000000..af5c322d15 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/actions.test.ts @@ -0,0 +1,286 @@ +/** + * 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 path from 'path'; +import fs from 'fs-extra'; +import {ThemePath, Components, createTempSiteDir} from './testUtils'; +import type {SwizzleAction} from '@docusaurus/types'; +import tree from 'tree-node-cli'; +import {eject, wrap} from '../actions'; +import {posixPath} from '@docusaurus/utils'; + +// use relative paths and sort files for tests +function stableCreatedFiles( + siteThemePath: string, + createdFiles: string[], +): string[] { + return createdFiles + .map((file) => posixPath(path.relative(siteThemePath, file))) + .sort(); +} + +describe('eject', () => { + async function testEject(action: SwizzleAction, componentName: string) { + const siteDir = await createTempSiteDir(); + const siteThemePath = path.join(siteDir, 'src/theme'); + const result = await eject({ + siteDir, + componentName, + themePath: ThemePath, + }); + return { + siteDir, + siteThemePath, + createdFiles: stableCreatedFiles(siteThemePath, result.createdFiles), + tree: tree(siteThemePath), + }; + } + + test(`eject ${Components.FirstLevelComponent}`, async () => { + const result = await testEject('eject', Components.FirstLevelComponent); + expect(result.createdFiles).toEqual([ + 'FirstLevelComponent.css', + 'FirstLevelComponent.tsx', + ]); + expect(result.tree).toMatchInlineSnapshot(` + "theme + ├── FirstLevelComponent.css + └── FirstLevelComponent.tsx" + `); + }); + + test(`eject ${Components.ComponentInSubFolder}`, async () => { + const result = await testEject('eject', Components.ComponentInSubFolder); + expect(result.createdFiles).toEqual([ + 'ComponentInFolder/ComponentInSubFolder/index.css', + 'ComponentInFolder/ComponentInSubFolder/index.tsx', + 'ComponentInFolder/ComponentInSubFolder/styles.css', + 'ComponentInFolder/ComponentInSubFolder/styles.module.css', + ]); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + └── ComponentInSubFolder + ├── index.css + ├── index.tsx + ├── styles.css + └── styles.module.css" + `); + }); + + test(`eject ${Components.ComponentInFolder}`, async () => { + const result = await testEject('eject', Components.ComponentInFolder); + expect(result.createdFiles).toEqual([ + // TODO do we really want to copy those Sibling components? + // It's hard to filter those reliably + // (index.* is not good, we need to include styles.css too) + 'ComponentInFolder/Sibling.css', + 'ComponentInFolder/Sibling.tsx', + 'ComponentInFolder/index.css', + 'ComponentInFolder/index.tsx', + ]); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + ├── Sibling.css + ├── Sibling.tsx + ├── index.css + └── index.tsx" + `); + }); +}); + +describe('wrap', () => { + async function testWrap( + action: SwizzleAction, + componentName: string, + {typescript}: {typescript: boolean} = {typescript: false}, + ) { + const siteDir = await createTempSiteDir(); + const siteThemePath = path.join(siteDir, 'src/theme'); + const result = await wrap({ + siteDir, + componentName, + themePath: ThemePath, + typescript, + }); + return { + siteDir, + siteThemePath, + createdFiles: stableCreatedFiles(siteThemePath, result.createdFiles), + firstFileContent: () => fs.readFile(result.createdFiles[0], 'utf8'), + tree: tree(siteThemePath), + }; + } + + describe('JavaScript', () => { + async function doWrap(componentName: string) { + return testWrap('wrap', componentName, { + typescript: false, + }); + } + + test(`wrap ${Components.FirstLevelComponent}`, async () => { + const result = await doWrap(Components.FirstLevelComponent); + expect(result.createdFiles).toEqual(['FirstLevelComponent.js']); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── FirstLevelComponent.js" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React from 'react'; + import FirstLevelComponent from '@theme-original/FirstLevelComponent'; + + export default function FirstLevelComponentWrapper(props) { + return ( + <> + + + ); + } + " + `); + }); + + test(`wrap ${Components.ComponentInSubFolder}`, async () => { + const result = await doWrap(Components.ComponentInSubFolder); + expect(result.createdFiles).toEqual([ + 'ComponentInFolder/ComponentInSubFolder/index.js', + ]); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + └── ComponentInSubFolder + └── index.js" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React from 'react'; + import ComponentInSubFolder from '@theme-original/ComponentInFolder/ComponentInSubFolder'; + + export default function ComponentInSubFolderWrapper(props) { + return ( + <> + + + ); + } + " + `); + }); + + test(`wrap ${Components.ComponentInFolder}`, async () => { + const result = await doWrap(Components.ComponentInFolder); + expect(result.createdFiles).toEqual(['ComponentInFolder/index.js']); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + └── index.js" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React from 'react'; + import ComponentInFolder from '@theme-original/ComponentInFolder'; + + export default function ComponentInFolderWrapper(props) { + return ( + <> + + + ); + } + " + `); + }); + }); + + describe('TypeScript', () => { + async function doWrap(componentName: string) { + return testWrap('wrap', componentName, { + typescript: true, + }); + } + + test(`wrap ${Components.FirstLevelComponent}`, async () => { + const result = await doWrap(Components.FirstLevelComponent); + expect(result.createdFiles).toEqual(['FirstLevelComponent.tsx']); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── FirstLevelComponent.tsx" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React, {ComponentProps} from 'react'; + import type FirstLevelComponentType from '@theme/FirstLevelComponent'; + import FirstLevelComponent from '@theme-original/FirstLevelComponent'; + + type Props = ComponentProps + + export default function FirstLevelComponentWrapper(props: Props): JSX.Element { + return ( + <> + + + ); + } + " + `); + }); + + test(`wrap ${Components.ComponentInSubFolder}`, async () => { + const result = await doWrap(Components.ComponentInSubFolder); + expect(result.createdFiles).toEqual([ + 'ComponentInFolder/ComponentInSubFolder/index.tsx', + ]); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + └── ComponentInSubFolder + └── index.tsx" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React, {ComponentProps} from 'react'; + import type ComponentInSubFolderType from '@theme/ComponentInFolder/ComponentInSubFolder'; + import ComponentInSubFolder from '@theme-original/ComponentInFolder/ComponentInSubFolder'; + + type Props = ComponentProps + + export default function ComponentInSubFolderWrapper(props: Props): JSX.Element { + return ( + <> + + + ); + } + " + `); + }); + + test(`wrap ${Components.ComponentInFolder}`, async () => { + const result = await doWrap(Components.ComponentInFolder); + expect(result.createdFiles).toEqual(['ComponentInFolder/index.tsx']); + expect(result.tree).toMatchInlineSnapshot(` + "theme + └── ComponentInFolder + └── index.tsx" + `); + await expect(result.firstFileContent()).resolves.toMatchInlineSnapshot(` + "import React, {ComponentProps} from 'react'; + import type ComponentInFolderType from '@theme/ComponentInFolder'; + import ComponentInFolder from '@theme-original/ComponentInFolder'; + + type Props = ComponentProps + + export default function ComponentInFolderWrapper(props: Props): JSX.Element { + return ( + <> + + + ); + } + " + `); + }); + }); +}); diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/components.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/components.test.ts new file mode 100644 index 0000000000..56718724af --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/components.test.ts @@ -0,0 +1,204 @@ +/** + * 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 path from 'path'; +import {getThemeComponents, readComponentNames} from '../components'; +import type {SwizzleConfig} from '@docusaurus/types'; +import {Components} from './testUtils'; + +const FixtureThemePath = path.join(__dirname, '__fixtures__/theme'); + +describe('readComponentNames', () => { + test('read theme', async () => { + await expect(readComponentNames(FixtureThemePath)).resolves.toEqual([ + Components.ComponentInFolder, + Components.ComponentInSubFolder, + Components.Sibling, + Components.FirstLevelComponent, + ]); + }); +}); + +describe('getThemeComponents', () => { + const themeName = 'myThemeName'; + const themePath = FixtureThemePath; + + const swizzleConfig: SwizzleConfig = { + components: { + [Components.ComponentInSubFolder]: { + actions: { + eject: 'safe', + wrap: 'unsafe', + }, + }, + [Components.ComponentInFolder]: { + actions: { + wrap: 'safe', + eject: 'unsafe', + }, + description: 'ComponentInFolder description', + }, + }, + }; + + test('read name', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect(themeComponents.themeName).toEqual(themeName); + }); + + test('read all', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect(themeComponents.all).toEqual([ + // Order matters! + Components.ComponentInFolder, + Components.ComponentInSubFolder, + Components.Sibling, + Components.FirstLevelComponent, + ]); + }); + + test('getConfig', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect(themeComponents.getConfig(Components.ComponentInFolder)) + .toMatchInlineSnapshot(` + Object { + "actions": Object { + "eject": "unsafe", + "wrap": "safe", + }, + "description": "ComponentInFolder description", + } + `); + expect(themeComponents.getConfig(Components.ComponentInSubFolder)) + .toMatchInlineSnapshot(` + Object { + "actions": Object { + "eject": "safe", + "wrap": "unsafe", + }, + } + `); + expect(themeComponents.getConfig(Components.FirstLevelComponent)) + .toMatchInlineSnapshot(` + Object { + "actions": Object { + "eject": "unsafe", + "wrap": "unsafe", + }, + "description": "N/A", + } + `); + + expect(() => + themeComponents.getConfig('DoesNotExistComp'), + ).toThrowErrorMatchingInlineSnapshot( + `"Can't get component config: component doesn't exist: DoesNotExistComp"`, + ); + }); + + test('getDescription', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect( + themeComponents.getDescription(Components.ComponentInFolder), + ).toEqual('ComponentInFolder description'); + expect( + themeComponents.getDescription(Components.ComponentInSubFolder), + ).toEqual('N/A'); + expect( + themeComponents.getDescription(Components.FirstLevelComponent), + ).toEqual('N/A'); + }); + + test('getActionStatus', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect( + themeComponents.getActionStatus(Components.ComponentInFolder, 'wrap'), + ).toEqual('safe'); + expect( + themeComponents.getActionStatus(Components.ComponentInFolder, 'eject'), + ).toEqual('unsafe'); + + expect( + themeComponents.getActionStatus(Components.ComponentInSubFolder, 'wrap'), + ).toEqual('unsafe'); + expect( + themeComponents.getActionStatus(Components.ComponentInSubFolder, 'eject'), + ).toEqual('safe'); + + expect( + themeComponents.getActionStatus(Components.FirstLevelComponent, 'wrap'), + ).toEqual('unsafe'); + expect( + themeComponents.getActionStatus(Components.FirstLevelComponent, 'eject'), + ).toEqual('unsafe'); + }); + + test('isSafeAction', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect( + themeComponents.isSafeAction(Components.ComponentInFolder, 'wrap'), + ).toEqual(true); + expect( + themeComponents.isSafeAction(Components.ComponentInFolder, 'eject'), + ).toEqual(false); + + expect( + themeComponents.isSafeAction(Components.ComponentInSubFolder, 'wrap'), + ).toEqual(false); + expect( + themeComponents.isSafeAction(Components.ComponentInSubFolder, 'eject'), + ).toEqual(true); + + expect( + themeComponents.isSafeAction(Components.FirstLevelComponent, 'wrap'), + ).toEqual(false); + expect( + themeComponents.isSafeAction(Components.FirstLevelComponent, 'eject'), + ).toEqual(false); + }); + + test('hasAnySafeAction', async () => { + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + expect( + themeComponents.hasAnySafeAction(Components.ComponentInFolder), + ).toEqual(true); + expect( + themeComponents.hasAnySafeAction(Components.ComponentInSubFolder), + ).toEqual(true); + expect( + themeComponents.hasAnySafeAction(Components.FirstLevelComponent), + ).toEqual(false); + }); +}); diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/config.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/config.test.ts new file mode 100644 index 0000000000..3bedcb2086 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/config.test.ts @@ -0,0 +1,131 @@ +/** + * 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 type {SwizzleConfig} from '@docusaurus/types'; +import {normalizeSwizzleConfig} from '../config'; + +describe('normalizeSwizzleConfig', () => { + test(`validate no components config`, async () => { + const config: SwizzleConfig = { + components: {}, + }; + expect(normalizeSwizzleConfig(config)).toEqual(config); + }); + + test(`validate complete config`, async () => { + const config: SwizzleConfig = { + components: { + SomeComponent: { + actions: { + wrap: 'safe', + eject: 'unsafe', + }, + description: 'SomeComponent description', + }, + 'Other/Component': { + actions: { + wrap: 'forbidden', + eject: 'unsafe', + }, + description: 'Other/Component description', + }, + }, + }; + expect(normalizeSwizzleConfig(config)).toEqual(config); + }); + + test(`normalize partial config`, async () => { + const config: SwizzleConfig = { + components: { + SomeComponent: { + // @ts-expect-error: incomplete actions map + actions: { + eject: 'safe', + }, + description: 'SomeComponent description', + }, + 'Other/Component': { + // @ts-expect-error: incomplete actions map + actions: { + wrap: 'forbidden', + }, + }, + }, + }; + expect(normalizeSwizzleConfig(config)).toMatchInlineSnapshot(` + Object { + "components": Object { + "Other/Component": Object { + "actions": Object { + "eject": "unsafe", + "wrap": "forbidden", + }, + }, + "SomeComponent": Object { + "actions": Object { + "eject": "safe", + "wrap": "unsafe", + }, + "description": "SomeComponent description", + }, + }, + } + `); + }); + + test(`reject missing components`, async () => { + // @ts-expect-error: incomplete actions map + const config: SwizzleConfig = {}; + + expect(() => + normalizeSwizzleConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `"Swizzle config does not match expected schema: \\"components\\" is required"`, + ); + }); + + test(`reject invalid action name`, async () => { + const config: SwizzleConfig = { + components: { + MyComponent: { + actions: { + wrap: 'safe', + eject: 'unsafe', + // @ts-expect-error: on purpose + bad: 'safe', + }, + }, + }, + }; + + expect(() => + normalizeSwizzleConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `"Swizzle config does not match expected schema: \\"components.MyComponent.actions.bad\\" is not allowed"`, + ); + }); + + test(`reject invalid action status`, async () => { + const config: SwizzleConfig = { + components: { + MyComponent: { + actions: { + wrap: 'safe', + // @ts-expect-error: on purpose + eject: 'invalid-status', + }, + }, + }, + }; + + expect(() => + normalizeSwizzleConfig(config), + ).toThrowErrorMatchingInlineSnapshot( + `"Swizzle config does not match expected schema: \\"components.MyComponent.actions.eject\\" must be one of [safe, unsafe, forbidden]"`, + ); + }); +}); diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts new file mode 100644 index 0000000000..f1bf3de6c5 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts @@ -0,0 +1,297 @@ +/** + * 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 path from 'path'; +import fs from 'fs-extra'; +import {ThemePath, createTempSiteDir, Components} from './testUtils'; +import tree from 'tree-node-cli'; +import swizzle from '../index'; +import {escapePath, Globby, posixPath} from '@docusaurus/utils'; + +const FixtureThemeName = 'fixture-theme-name'; + +// TODO is it really worth it to duplicate fixtures? +const ThemePathJS = ThemePath; +const ThemePathTS = ThemePath; + +async function createTestSiteConfig(siteDir: string) { + const configPath = path.join(siteDir, 'docusaurus.config.js'); + + await fs.writeFile( + configPath, + ` +module.exports = { + title: 'My Site', + tagline: 'Dinosaurs are cool', + url: 'https://your-docusaurus-test-site.com', + baseUrl: '/', + themes: [ + function fixtureTheme() { + return { + name: '${FixtureThemeName}', + getThemePath() { + return '${escapePath(ThemePathJS)}'; + }, + getTypeScriptThemePath() { + return '${escapePath(ThemePathTS)}'; + }, + }; + }, + ], +}`, + ); +} + +class MockExitError extends Error { + constructor(public code: number) { + super(`Exit with code ${code}`); + this.code = code; + } +} + +function createExitMock() { + let mock: jest.SpyInstance; + + beforeEach(async () => { + mock = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new MockExitError(code as number); + }); + }); + afterEach(async () => { + mock?.mockRestore(); + }); + + return { + expectExitCode: (code: number) => { + expect(mock).toHaveBeenCalledWith(code); + }, + }; +} + +const swizzleWithExit: typeof swizzle = async (...args) => { + await expect(() => swizzle(...args)).rejects.toThrow(MockExitError); +}; + +async function createTestSite() { + const siteDir = await createTempSiteDir(); + + await createTestSiteConfig(siteDir); + + const siteThemePath = path.join(siteDir, 'src/theme'); + await fs.ensureDir(siteThemePath); + + async function snapshotThemeDir() { + const siteThemePathPosix = posixPath(siteThemePath); + expect(tree(siteThemePathPosix)).toMatchSnapshot('theme dir tree'); + + const files = Globby.sync(siteThemePathPosix) + .map((file) => path.posix.relative(siteThemePathPosix, file)) + .sort(); + + for (const file of files) { + const fileContent = await fs.readFile( + path.posix.join(siteThemePath, file), + 'utf-8', + ); + expect(fileContent).toMatchSnapshot(file); + } + } + + function testWrap({ + component, + typescript, + }: { + component: string; + typescript?: boolean; + }) { + return swizzleWithExit(siteDir, FixtureThemeName, component, { + wrap: true, + danger: true, + typescript, + }); + } + + function testEject({ + component, + typescript, + }: { + component: string; + typescript?: boolean; + }) { + return swizzleWithExit(siteDir, FixtureThemeName, component, { + eject: true, + danger: true, + typescript, + }); + } + + return { + siteDir, + siteThemePath, + snapshotThemeDir, + testWrap, + testEject, + }; +} + +describe('swizzle wrap', () => { + const exitMock = createExitMock(); + + test(`${Components.FirstLevelComponent} JS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.FirstLevelComponent, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.FirstLevelComponent} TS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.FirstLevelComponent, + typescript: true, + }); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInFolder} JS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.ComponentInFolder, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInFolder} TS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.ComponentInFolder, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInSubFolder} JS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.ComponentInSubFolder, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInSubFolder} TS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.ComponentInSubFolder, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.Sibling} JS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.Sibling, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.Sibling} TS`, async () => { + const {snapshotThemeDir, testWrap} = await createTestSite(); + await testWrap({ + component: Components.Sibling, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); +}); + +describe('swizzle eject', () => { + const exitMock = createExitMock(); + + test(`${Components.FirstLevelComponent} JS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.FirstLevelComponent, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.FirstLevelComponent} TS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.FirstLevelComponent, + typescript: true, + }); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInFolder} JS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.ComponentInFolder, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInFolder} TS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.ComponentInFolder, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInSubFolder} JS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.ComponentInSubFolder, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.ComponentInSubFolder} TS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.ComponentInSubFolder, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.Sibling} JS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.Sibling, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); + + test(`${Components.Sibling} TS`, async () => { + const {snapshotThemeDir, testEject} = await createTestSite(); + await testEject({ + component: Components.Sibling, + typescript: true, + }); + exitMock.expectExitCode(0); + await snapshotThemeDir(); + }); +}); diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/testUtils.ts b/packages/docusaurus/src/commands/swizzle/__tests__/testUtils.ts new file mode 100644 index 0000000000..53a4ee3090 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/__tests__/testUtils.ts @@ -0,0 +1,23 @@ +/** + * 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 path from 'path'; +import os from 'os'; +import fs from 'fs-extra'; + +export const ThemePath = path.join(__dirname, '__fixtures__/theme'); + +export const Components = { + ComponentInSubFolder: 'ComponentInFolder/ComponentInSubFolder', + Sibling: 'ComponentInFolder/Sibling', + ComponentInFolder: 'ComponentInFolder', + FirstLevelComponent: 'FirstLevelComponent', +}; + +export async function createTempSiteDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'docusaurus-test-swizzle-sitedir')); +} diff --git a/packages/docusaurus/src/commands/swizzle/actions.ts b/packages/docusaurus/src/commands/swizzle/actions.ts new file mode 100644 index 0000000000..640912d35a --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/actions.ts @@ -0,0 +1,151 @@ +/** + * 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 logger from '@docusaurus/logger'; +import fs from 'fs-extra'; +import path from 'path'; +import _ from 'lodash'; +import {Globby, posixPath, THEME_PATH} from '@docusaurus/utils'; +import type {SwizzleAction, SwizzleComponentConfig} from '@docusaurus/types'; +import type {SwizzleOptions} from './common'; +import {askSwizzleAction} from './prompts'; + +export const SwizzleActions: SwizzleAction[] = ['wrap', 'eject']; + +export async function getAction( + componentConfig: SwizzleComponentConfig, + options: Pick, +): Promise { + if (options.wrap) { + return 'wrap'; + } + if (options.eject) { + return 'eject'; + } + return askSwizzleAction(componentConfig); +} + +export type ActionParams = { + siteDir: string; + themePath: string; + componentName: string; +}; + +export type ActionResult = { + createdFiles: string[]; +}; + +async function isDir(dirPath: string): Promise { + return ( + (await fs.pathExists(dirPath)) && (await fs.stat(dirPath)).isDirectory() + ); +} + +export async function eject({ + siteDir, + themePath, + componentName, +}: ActionParams): Promise { + const fromPath = path.join(themePath, componentName); + const isDirectory = await isDir(fromPath); + const globPattern = isDirectory + ? // do we really want to copy all components? + path.join(fromPath, '*') + : `${fromPath}.*`; + + const globPatternPosix = posixPath(globPattern); + + const filesToCopy = await Globby(globPatternPosix, { + ignore: ['**/*.{story,stories,test,tests}.{js,jsx,ts,tsx}'], + }); + + if (filesToCopy.length === 0) { + // This should never happen + throw new Error( + logger.interpolate`No files to copy from path=${fromPath} with glob code=${globPatternPosix}`, + ); + } + + const toPath = isDirectory + ? path.join(siteDir, THEME_PATH, componentName) + : path.join(siteDir, THEME_PATH); + + await fs.ensureDir(toPath); + + const createdFiles = await Promise.all( + filesToCopy.map(async (sourceFile: string) => { + const fileName = path.basename(sourceFile); + const targetFile = path.join(toPath, fileName); + try { + await fs.copy(sourceFile, targetFile, {overwrite: true}); + } catch (err) { + throw new Error( + logger.interpolate`Could not copy file from ${sourceFile} to ${targetFile}`, + ); + } + return targetFile; + }), + ); + return {createdFiles}; +} + +export async function wrap({ + siteDir, + themePath, + componentName: themeComponentName, + typescript, + importType = 'original', +}: ActionParams & { + typescript: boolean; + importType?: 'original' | 'init'; +}): Promise { + const isDirectory = await isDir(path.join(themePath, themeComponentName)); + + // Top/Parent/ComponentName => ComponentName + const componentName = _.last(themeComponentName.split('/')); + const wrapperComponentName = `${componentName}Wrapper`; + + const wrapperFileName = `${themeComponentName}${isDirectory ? '/index' : ''}${ + typescript ? '.tsx' : '.js' + }`; + + await fs.ensureDir(path.resolve(siteDir, THEME_PATH)); + + const toPath = path.resolve(siteDir, THEME_PATH, wrapperFileName); + + const content = typescript + ? `import React, {ComponentProps} from 'react'; +import type ${componentName}Type from '@theme/${themeComponentName}'; +import ${componentName} from '@theme-${importType}/${themeComponentName}'; + +type Props = ComponentProps + +export default function ${wrapperComponentName}(props: Props): JSX.Element { + return ( + <> + <${componentName} {...props} /> + + ); +} +` + : `import React from 'react'; +import ${componentName} from '@theme-${importType}/${themeComponentName}'; + +export default function ${wrapperComponentName}(props) { + return ( + <> + <${componentName} {...props} /> + + ); +} +`; + + await fs.ensureDir(path.dirname(toPath)); + await fs.writeFile(toPath, content); + + return {createdFiles: [toPath]}; +} diff --git a/packages/docusaurus/src/commands/swizzle/common.ts b/packages/docusaurus/src/commands/swizzle/common.ts new file mode 100644 index 0000000000..84449a009d --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/common.ts @@ -0,0 +1,98 @@ +/** + * 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 leven from 'leven'; +import _ from 'lodash'; +import logger from '@docusaurus/logger'; +import type { + InitializedPlugin, + SwizzleAction, + SwizzleActionStatus, +} from '@docusaurus/types'; +import type {NormalizedPluginConfig} from '../../server/plugins/init'; + +export const SwizzleActions: SwizzleAction[] = ['wrap', 'eject']; + +export const SwizzleActionsStatuses: SwizzleActionStatus[] = [ + 'safe', + 'unsafe', + 'forbidden', +]; + +export const PartiallySafeHint = logger.red('*'); + +export function actionStatusLabel(status: SwizzleActionStatus): string { + return _.capitalize(status); +} + +const SwizzleActionStatusColors: Record< + SwizzleActionStatus, + (str: string) => string +> = { + safe: logger.green, + unsafe: logger.yellow, + forbidden: logger.red, +}; + +export function actionStatusColor( + status: SwizzleActionStatus, + str: string, +): string { + const colorFn = SwizzleActionStatusColors[status]; + return colorFn(str); +} + +export function actionStatusSuffix( + status: SwizzleActionStatus, + options: {partiallySafe?: boolean} = {}, +): string { + return ` (${actionStatusColor(status, actionStatusLabel(status))}${ + options.partiallySafe ? PartiallySafeHint : '' + })`; +} + +export type SwizzlePlugin = { + instance: InitializedPlugin; + plugin: NormalizedPluginConfig; +}; + +export type SwizzleContext = {plugins: SwizzlePlugin[]}; + +export type SwizzleOptions = { + typescript: boolean; + danger: boolean; + list: boolean; + wrap: boolean; + eject: boolean; +}; + +export function normalizeOptions( + options: Partial, +): SwizzleOptions { + return { + typescript: options.typescript ?? false, + danger: options.danger ?? false, + list: options.list ?? false, + wrap: options.wrap ?? false, + eject: options.eject ?? false, + }; +} + +export function findStringIgnoringCase( + str: string, + values: string[], +): string | undefined { + return values.find((v) => v.toLowerCase() === str.toLowerCase()); +} + +export function findClosestValue( + str: string, + values: string[], + maxLevenshtein = 3, +): string | undefined { + return values.find((v) => leven(v, str) <= maxLevenshtein); +} diff --git a/packages/docusaurus/src/commands/swizzle/components.ts b/packages/docusaurus/src/commands/swizzle/components.ts new file mode 100644 index 0000000000..33968ad0fa --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/components.ts @@ -0,0 +1,264 @@ +/** + * 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 logger from '@docusaurus/logger'; +import fs from 'fs-extra'; +import path from 'path'; +import _ from 'lodash'; +import type { + SwizzleAction, + SwizzleActionStatus, + SwizzleComponentConfig, + SwizzleConfig, +} from '@docusaurus/types'; +import {posixPath} from '@docusaurus/utils'; +import {askComponentName} from './prompts'; +import {findClosestValue, findStringIgnoringCase} from './common'; +import {helpTables, themeComponentsTable} from './tables'; +import {SwizzleActions} from './actions'; + +export type ThemeComponents = { + themeName: string; + all: string[]; + getConfig: (component: string) => SwizzleComponentConfig; + getDescription: (component: string) => string; + getActionStatus: ( + component: string, + action: SwizzleAction, + ) => SwizzleActionStatus; + isSafeAction: (component: string, action: SwizzleAction) => boolean; + hasAnySafeAction: (component: string) => boolean; + hasAllSafeAction: (component: string) => boolean; +}; + +const formatComponentName = (componentName: string): string => + componentName.replace(/[/\\]index\.[jt]sx?/, '').replace(/\.[jt]sx?/, ''); + +const skipReadDirNames = ['__test__', '__tests__', '__mocks__', '__fixtures__']; + +export async function readComponentNames(themePath: string): Promise { + type File = {file: string; fullPath: string; isDir: boolean}; + type ComponentFile = File & {componentName: string}; + + if (!(await fs.pathExists(themePath))) { + return []; + } + + async function walk(dir: string): Promise { + const files: File[] = await Promise.all( + ( + await fs.readdir(dir) + ).flatMap(async (file) => { + const fullPath = path.join(dir, file); + const stat = await fs.stat(fullPath); + const isDir = stat.isDirectory(); + return {file, fullPath, isDir}; + }), + ); + + return ( + await Promise.all( + files.map(async (file) => { + if (file.isDir) { + if (skipReadDirNames.includes(file.file)) { + return []; + } + return walk(file.fullPath); + } else if ( + // TODO can probably be refactored + /(? f.componentName], + ['asc'], + ); + + return componentFilesOrdered.map((f) => f.componentName); +} + +export function listComponentNames(themeComponents: ThemeComponents): string { + if (themeComponents.all.length === 0) { + return 'No component to swizzle.'; + } + return `${themeComponentsTable(themeComponents)} + +${helpTables()} +`; +} + +export async function getThemeComponents({ + themeName, + themePath, + swizzleConfig, +}: { + themeName: string; + themePath: string; + swizzleConfig: SwizzleConfig; +}): Promise { + const FallbackSwizzleActionStatus: SwizzleActionStatus = 'unsafe'; + const FallbackSwizzleComponentDescription = 'N/A'; + const FallbackSwizzleComponentConfig: SwizzleComponentConfig = { + actions: { + wrap: FallbackSwizzleActionStatus, + eject: FallbackSwizzleActionStatus, + }, + description: FallbackSwizzleComponentDescription, + }; + + const allComponents = await readComponentNames(themePath); + + function getConfig(component: string): SwizzleComponentConfig { + if (!allComponents.includes(component)) { + throw new Error( + `Can't get component config: component doesn't exist: ${component}`, + ); + } + return ( + swizzleConfig.components[component] ?? FallbackSwizzleComponentConfig + ); + } + + function getDescription(component: string): string { + return ( + getConfig(component).description ?? FallbackSwizzleComponentDescription + ); + } + + function getActionStatus( + component: string, + action: SwizzleAction, + ): SwizzleActionStatus { + return getConfig(component).actions[action] ?? FallbackSwizzleActionStatus; + } + + function isSafeAction(component: string, action: SwizzleAction): boolean { + return getActionStatus(component, action) === 'safe'; + } + + function hasAllSafeAction(component: string): boolean { + return SwizzleActions.every((action) => isSafeAction(component, action)); + } + + function hasAnySafeAction(component: string): boolean { + return SwizzleActions.some((action) => isSafeAction(component, action)); + } + + // Present the safest components first + const orderedComponents = _.orderBy( + allComponents, + [ + hasAllSafeAction, + (component) => isSafeAction(component, 'wrap'), + (component) => isSafeAction(component, 'eject'), + (component) => component, + ], + ['desc', 'desc', 'desc', 'asc'], + ); + + return { + themeName, + all: orderedComponents, + getConfig, + getDescription, + getActionStatus, + isSafeAction, + hasAnySafeAction, + hasAllSafeAction, + }; +} + +// Returns a valid value if recovering is possible +function handleInvalidComponentNameParam({ + componentNameParam, + themeComponents, +}: { + componentNameParam: string; + themeComponents: ThemeComponents; +}): string { + // Trying to recover invalid value + // We look for potential matches that only differ in casing. + const differentCaseMatch = findStringIgnoringCase( + componentNameParam, + themeComponents.all, + ); + if (differentCaseMatch) { + logger.warn`Component name=${componentNameParam} doesn't exist.`; + logger.info`name=${differentCaseMatch} will be used instead of name=${componentNameParam}.`; + return differentCaseMatch; + } + + // No recovery value is possible: print error + logger.error`Component name=${componentNameParam} not found.`; + const suggestion = findClosestValue(componentNameParam, themeComponents.all); + if (suggestion) { + logger.info`Did you mean name=${suggestion}? ${ + themeComponents.hasAnySafeAction(suggestion) + ? `Note: this component is an unsafe internal component and can only be swizzled with code=${'--danger'} or explicit confirmation.` + : '' + }`; + } else { + logger.info(listComponentNames(themeComponents)); + } + return process.exit(1); +} + +async function handleComponentNameParam({ + componentNameParam, + themeComponents, +}: { + componentNameParam: string; + themeComponents: ThemeComponents; +}): Promise { + const isValidName = themeComponents.all.includes(componentNameParam); + if (!isValidName) { + return handleInvalidComponentNameParam({ + componentNameParam, + themeComponents, + }); + } + return componentNameParam; +} + +export async function getComponentName({ + componentNameParam, + themeComponents, + list, +}: { + componentNameParam: string | undefined; + themeComponents: ThemeComponents; + list: boolean | undefined; +}): Promise { + if (list) { + logger.info(listComponentNames(themeComponents)); + return process.exit(0); + } + const componentName: string = componentNameParam + ? await handleComponentNameParam({ + componentNameParam, + themeComponents, + }) + : await askComponentName(themeComponents); + + return componentName; +} diff --git a/packages/docusaurus/src/commands/swizzle/config.ts b/packages/docusaurus/src/commands/swizzle/config.ts new file mode 100644 index 0000000000..d7e4ffb7fb --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/config.ts @@ -0,0 +1,113 @@ +/** + * 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 {Joi} from '@docusaurus/utils-validation'; +import type {SwizzleComponentConfig, SwizzleConfig} from '@docusaurus/types'; +import type {SwizzlePlugin} from './common'; +import {SwizzleActions, SwizzleActionsStatuses} from './common'; +import {getPluginByThemeName} from './themes'; + +function getModuleSwizzleConfig( + swizzlePlugin: SwizzlePlugin, +): SwizzleConfig | undefined { + const getSwizzleConfig = + swizzlePlugin.plugin.plugin?.getSwizzleConfig ?? + swizzlePlugin.plugin.pluginModule?.module.getSwizzleConfig ?? + swizzlePlugin.plugin.pluginModule?.module?.getSwizzleConfig; + + if (getSwizzleConfig) { + return getSwizzleConfig(); + } + + // TODO deprecate getSwizzleComponentList later + const getSwizzleComponentList = + swizzlePlugin.plugin.plugin?.getSwizzleComponentList ?? + swizzlePlugin.plugin.pluginModule?.module.getSwizzleComponentList ?? + swizzlePlugin.plugin.pluginModule?.module?.getSwizzleComponentList; + + if (getSwizzleComponentList) { + const safeComponents = getSwizzleComponentList() ?? []; + const safeComponentConfig: SwizzleComponentConfig = { + actions: { + eject: 'safe', + wrap: 'safe', + }, + description: undefined, + }; + return { + components: Object.fromEntries( + safeComponents.map((comp) => [comp, safeComponentConfig]), + ), + }; + } + + return undefined; +} + +export function normalizeSwizzleConfig( + unsafeSwizzleConfig: unknown, +): SwizzleConfig { + const schema = Joi.object({ + components: Joi.object() + .pattern( + Joi.string(), + Joi.object({ + actions: Joi.object().pattern( + Joi.string().valid(...SwizzleActions), + Joi.string().valid(...SwizzleActionsStatuses), + ), + description: Joi.string(), + }), + ) + .required(), + }); + + const result = schema.validate(unsafeSwizzleConfig); + + if (result.error) { + throw new Error( + `Swizzle config does not match expected schema: ${result.error.message}`, + ); + } + + const swizzleConfig: SwizzleConfig = result.value; + + // Ensure all components always declare all actions + Object.values(swizzleConfig.components).forEach((componentConfig) => { + SwizzleActions.forEach((action) => { + if (!componentConfig.actions[action]) { + componentConfig.actions[action] = 'unsafe'; + } + }); + }); + + return swizzleConfig; +} + +const FallbackSwizzleConfig: SwizzleConfig = { + components: {}, +}; + +export function getThemeSwizzleConfig( + themeName: string, + plugins: SwizzlePlugin[], +): SwizzleConfig { + const plugin = getPluginByThemeName(plugins, themeName); + const config = getModuleSwizzleConfig(plugin); + if (config) { + try { + return normalizeSwizzleConfig(config); + } catch (e) { + throw new Error( + `Invalid Swizzle config for theme ${themeName}.\n${ + (e as Error).message + }`, + ); + } + } + return FallbackSwizzleConfig; +} diff --git a/packages/docusaurus/src/commands/swizzle/context.ts b/packages/docusaurus/src/commands/swizzle/context.ts new file mode 100644 index 0000000000..92722262a6 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/context.ts @@ -0,0 +1,37 @@ +/** + * 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 {createRequire} from 'module'; +import {loadContext, loadPluginConfigs} from '../../server'; +import initPlugins, {normalizePluginConfigs} from '../../server/plugins/init'; +import type {InitializedPlugin} from '@docusaurus/types'; +import type {SwizzleContext} from './common'; + +export async function initSwizzleContext( + siteDir: string, +): Promise { + const context = await loadContext(siteDir); + const pluginRequire = createRequire(context.siteConfigPath); + + const pluginConfigs = await loadPluginConfigs(context); + const plugins: InitializedPlugin[] = await initPlugins({ + pluginConfigs, + context, + }); + + const pluginsNormalized = await normalizePluginConfigs( + pluginConfigs, + pluginRequire, + ); + + return { + plugins: plugins.map((plugin, pluginIndex) => ({ + plugin: pluginsNormalized[pluginIndex], + instance: plugin, + })), + }; +} diff --git a/packages/docusaurus/src/commands/swizzle/index.ts b/packages/docusaurus/src/commands/swizzle/index.ts new file mode 100644 index 0000000000..a391bd1cc9 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/index.ts @@ -0,0 +1,159 @@ +/** + * 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 logger from '@docusaurus/logger'; +import {getThemeName, getThemePath, getThemeNames} from './themes'; +import {getThemeComponents, getComponentName} from './components'; +import {helpTables, themeComponentsTable} from './tables'; +import type {SwizzleAction, SwizzleComponentConfig} from '@docusaurus/types'; +import type {SwizzleOptions, SwizzlePlugin} from './common'; +import {normalizeOptions} from './common'; +import type {ActionResult} from './actions'; +import {eject, getAction, wrap} from './actions'; +import {getThemeSwizzleConfig} from './config'; +import {askSwizzleDangerousComponent} from './prompts'; +import {initSwizzleContext} from './context'; + +async function listAllThemeComponents({ + themeNames, + plugins, + typescript, +}: { + themeNames: string[]; + plugins: SwizzlePlugin[]; + typescript: SwizzleOptions['typescript']; +}) { + const themeComponentsTables = ( + await Promise.all( + themeNames.map(async (themeName) => { + const themePath = getThemePath({themeName, plugins, typescript}); + const swizzleConfig = getThemeSwizzleConfig(themeName, plugins); + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + return themeComponentsTable(themeComponents); + }), + ) + ).join('\n\n'); + + logger.info(`All theme components available to swizzle: + +${themeComponentsTables} + +${helpTables()} + `); + return process.exit(0); +} + +async function ensureActionSafety({ + componentName, + componentConfig, + action, + danger, +}: { + componentName: string; + componentConfig: SwizzleComponentConfig; + action: SwizzleAction; + danger: boolean; +}): Promise { + const actionStatus = componentConfig.actions[action]; + + if (actionStatus === 'forbidden') { + logger.error` +Swizzle action name=${action} is forbidden for component name=${componentName} +`; + return process.exit(1); + } + + if (actionStatus === 'unsafe' && !danger) { + logger.warn` +Swizzle action name=${action} is unsafe to perform on name=${componentName}. +It is more likely to be affected by breaking changes in the future +If you want to swizzle it, use the code=${'--danger'} flag, or confirm that you understand the risks. +`; + const swizzleDangerousComponent = await askSwizzleDangerousComponent(); + if (!swizzleDangerousComponent) { + return process.exit(1); + } + } + + return undefined; +} + +export default async function swizzle( + siteDir: string, + themeNameParam: string | undefined, + componentNameParam: string | undefined, + optionsParam: Partial, +): Promise { + const options = normalizeOptions(optionsParam); + const {list, danger, typescript} = options; + + const {plugins} = await initSwizzleContext(siteDir); + const themeNames = getThemeNames(plugins); + + if (list && !themeNameParam) { + await listAllThemeComponents({themeNames, plugins, typescript}); + } + + const themeName = await getThemeName({themeNameParam, themeNames, list}); + const themePath = getThemePath({themeName, plugins, typescript}); + const swizzleConfig = getThemeSwizzleConfig(themeName, plugins); + + const themeComponents = await getThemeComponents({ + themeName, + themePath, + swizzleConfig, + }); + + const componentName = await getComponentName({ + componentNameParam, + themeComponents, + list, + }); + const componentConfig = themeComponents.getConfig(componentName); + + const action = await getAction(componentConfig, options); + + await ensureActionSafety({componentName, componentConfig, action, danger}); + + async function executeAction(): Promise { + switch (action) { + case 'wrap': { + const result = await wrap({ + siteDir, + themePath, + componentName, + typescript, + }); + logger.success` +Created wrapper of name=${componentName} from name=${themeName} in path=${result.createdFiles}. +`; + return result; + } + case 'eject': { + const result = await eject({ + siteDir, + themePath, + componentName, + }); + logger.success` +Ejected name=${componentName} from name=${themeName} to path=${result.createdFiles}. +`; + return result; + } + default: + throw new Error(`Unexpected action ${action}`); + } + } + + await executeAction(); + + return process.exit(0); +} diff --git a/packages/docusaurus/src/commands/swizzle/prompts.ts b/packages/docusaurus/src/commands/swizzle/prompts.ts new file mode 100644 index 0000000000..fb6ab4f13d --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/prompts.ts @@ -0,0 +1,127 @@ +/** + * 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 logger from '@docusaurus/logger'; +import prompts from 'prompts'; +import type {ThemeComponents} from './components'; +import type {SwizzleAction, SwizzleComponentConfig} from '@docusaurus/types'; +import {actionStatusSuffix, PartiallySafeHint} from './common'; + +const ExitTitle = logger.yellow('[Exit]'); + +export async function askThemeName(themeNames: string[]): Promise { + const {themeName} = await prompts({ + type: 'select', + name: 'themeName', + message: 'Select a theme to swizzle:', + choices: themeNames + .map((theme) => ({title: theme, value: theme})) + .concat({title: ExitTitle, value: '[Exit]'}), + }); + if (!themeName || themeName === '[Exit]') { + process.exit(0); + } + return themeName; +} + +export async function askComponentName( + themeComponents: ThemeComponents, +): Promise { + function formatComponentName(componentName: string): string { + const anySafe = themeComponents.hasAnySafeAction(componentName); + const allSafe = themeComponents.hasAllSafeAction(componentName); + const safestStatus = anySafe ? 'safe' : 'unsafe'; // Not 100% accurate but good enough for now. + const partiallySafe = anySafe && !allSafe; + return `${componentName}${actionStatusSuffix(safestStatus, { + partiallySafe, + })}`; + } + + const {componentName} = await prompts({ + type: 'autocomplete', + name: 'componentName', + message: ` +Select or type the component to swizzle. +${PartiallySafeHint} = not safe for all swizzle actions +`, + // This doesn't work well in small-height terminals (like IDE) + // limit: 30, + // This does not work well and messes up with terminal scroll position + // limit: Number.POSITIVE_INFINITY, + choices: themeComponents.all + .map((compName) => ({ + title: formatComponentName(compName), + value: compName, + })) + .concat({title: ExitTitle, value: '[Exit]'}), + async suggest(input, choices) { + return choices.filter((choice) => + choice.title.toLowerCase().includes(input.toLowerCase()), + ); + }, + }); + logger.newLine(); + + if (!componentName || componentName === '[Exit]') { + return process.exit(0); + } + + return componentName; +} + +export async function askSwizzleDangerousComponent(): Promise { + const {switchToDanger} = await prompts({ + type: 'select', + name: 'switchToDanger', + message: `Do you really want to swizzle this unsafe internal component?`, + choices: [ + {title: logger.green('NO: cancel and stay safe'), value: false}, + { + title: logger.red('YES: I know what I am doing!'), + value: true, + }, + {title: ExitTitle, value: '[Exit]'}, + ], + }); + + if (typeof switchToDanger === 'undefined' || switchToDanger === '[Exit]') { + return process.exit(0); + } + + return !!switchToDanger; +} + +export async function askSwizzleAction( + componentConfig: SwizzleComponentConfig, +): Promise { + const {action} = await prompts({ + type: 'select', + name: 'action', + message: `Which swizzle action do you want to do?`, + choices: [ + { + title: `${logger.bold('Wrap')}${actionStatusSuffix( + componentConfig.actions.wrap, + )}`, + value: 'wrap', + }, + { + title: `${logger.bold('Eject')}${actionStatusSuffix( + componentConfig.actions.eject, + )}`, + value: 'eject', + }, + {title: ExitTitle, value: '[Exit]'}, + ], + }); + + if (typeof action === 'undefined' || action === '[Exit]') { + return process.exit(0); + } + + return action; +} diff --git a/packages/docusaurus/src/commands/swizzle/tables.ts b/packages/docusaurus/src/commands/swizzle/tables.ts new file mode 100644 index 0000000000..5816d76ba8 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/tables.ts @@ -0,0 +1,140 @@ +/** + * 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 logger from '@docusaurus/logger'; +import Table from 'cli-table3'; +import _ from 'lodash'; +import type {ThemeComponents} from './components'; +import {SwizzleActions} from './actions'; +import type {SwizzleActionStatus} from '@docusaurus/types'; +import {actionStatusColor, actionStatusLabel} from './common'; + +function tableStatusLabel(status: SwizzleActionStatus): string { + return actionStatusColor(status, actionStatusLabel(status)); +} + +function getStatusLabel(status: SwizzleActionStatus): string { + return actionStatusColor(status, actionStatusLabel(status)); +} + +function statusTable(): string { + const table = new Table({ + head: ['Status', 'CLI option', 'Description'], + }); + + table.push({ + [tableStatusLabel('safe')]: [ + '', + ` +This component is safe to swizzle and was designed for this purpose. +The swizzled component is retro-compatible with minor version upgrades. +`, + ], + }); + + table.push({ + [tableStatusLabel('unsafe')]: [ + logger.code('--danger'), + ` +This component is unsafe to swizzle, but you can still do it! +Warning: we may release breaking changes within minor version upgrades. +You will have to upgrade your component manually and maintain it over time. + +${logger.green( + 'Tip', +)}: your customization can't be done in a ${tableStatusLabel('safe')} way? +Report it here: https://github.com/facebook/docusaurus/discussions/5468 +`, + ], + }); + + table.push({ + [tableStatusLabel('forbidden')]: [ + '', + ` +This component should not meant to be swizzled. +`, + ], + }); + + return table.toString(); +} + +function actionsTable(): string { + const table = new Table({ + head: ['Actions', 'CLI option', 'Description'], + }); + + table.push({ + [logger.bold('Wrap')]: [ + logger.code('--wrap'), + ` +Creates a wrapper around the original theme component. +Allows rendering other components before/after the original theme component. + +${logger.green('Tip')}: prefer ${logger.code( + '--wrap', + )} whenever possible to reduces the amount of code to maintain. + `, + ], + }); + + table.push({ + [logger.bold('Eject')]: [ + logger.code('--eject'), + ` +Ejects the full source code of the original theme component. +Allows overriding the original component entirely with your own UI and logic. + +${logger.green('Tip')}: ${logger.code( + '--eject', + )} can be useful to completely redesign a component. +`, + ], + }); + + return table.toString(); +} + +export function helpTables(): string { + return `${logger.bold('Swizzle actions')}: +${actionsTable()} + +${logger.bold('Swizzle safety statuses')}: +${statusTable()} + +${logger.bold('Swizzle guide')}: https://docusaurus.io/docs/swizzling`; +} + +export function themeComponentsTable(themeComponents: ThemeComponents): string { + const table = new Table({ + head: [ + 'Component name', + ...SwizzleActions.map((action) => _.capitalize(action)), + 'Description', + ], + }); + + themeComponents.all.forEach((component) => { + table.push({ + [component]: [ + ...SwizzleActions.map((action) => + getStatusLabel(themeComponents.getActionStatus(component, action)), + ), + themeComponents.getDescription(component), + ], + }); + }); + + return `${logger.bold( + `Components available for swizzle in ${logger.name( + themeComponents.themeName, + )}`, + )}: +${table.toString()} +`; +} diff --git a/packages/docusaurus/src/commands/swizzle/themes.ts b/packages/docusaurus/src/commands/swizzle/themes.ts new file mode 100644 index 0000000000..ead1fc6999 --- /dev/null +++ b/packages/docusaurus/src/commands/swizzle/themes.ts @@ -0,0 +1,149 @@ +/** + * 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 logger from '@docusaurus/logger'; +import leven from 'leven'; +import _ from 'lodash'; +import {askThemeName} from './prompts'; +import {findStringIgnoringCase, type SwizzlePlugin} from './common'; + +export function pluginToThemeName(plugin: SwizzlePlugin): string | undefined { + if (plugin.instance.getThemePath) { + return ( + (plugin.instance.version as {name: string}).name ?? plugin.instance.name + ); + } + return undefined; +} + +export function getPluginByThemeName( + plugins: SwizzlePlugin[], + themeName: string, +): SwizzlePlugin { + const plugin = plugins.find((p) => pluginToThemeName(p) === themeName); + if (!plugin) { + throw new Error(`Theme ${themeName} not found`); + } + return plugin; +} + +export function getThemeNames(plugins: SwizzlePlugin[]): string[] { + const themeNames = _.uniq( + // The fact that getThemePath is attached to the plugin instance makes + // this code impossible to optimize. If this is a static method, we don't + // need to initialize all plugins just to filter which are themes + // Benchmark: loadContext-58ms; initPlugins-323ms + plugins.map((plugin) => pluginToThemeName(plugin)).filter(Boolean), + ) as string[]; + + // Opinionated ordering: user is most likely to swizzle: + // - the classic theme + // - official themes + // - official plugins + return _.orderBy( + themeNames, + [ + (t) => t === '@docusaurus/theme-classic', + (t) => t.includes('@docusaurus/theme'), + (t) => t.includes('@docusaurus'), + ], + ['desc', 'desc', 'desc'], + ); +} + +// Returns a valid value if recovering is possible +function handleInvalidThemeName({ + themeNameParam, + themeNames, +}: { + themeNameParam: string; + themeNames: string[]; +}): string { + // Trying to recover invalid value + // We look for potential matches that only differ in casing. + const differentCaseMatch = findStringIgnoringCase(themeNameParam, themeNames); + if (differentCaseMatch) { + logger.warn`Theme name=${themeNameParam} doesn't exist.`; + logger.info`name=${differentCaseMatch} will be used instead of name=${themeNameParam}.`; + return differentCaseMatch; + } + + // TODO recover from short theme-names here: "classic" => "@docusaurus/theme-classic" + + // No recovery value is possible: print error + const suggestion = themeNames.find( + (name) => leven(name, themeNameParam!) < 4, + ); + logger.error`Theme name=${themeNameParam} not found. ${ + suggestion + ? logger.interpolate`Did you mean name=${suggestion}?` + : logger.interpolate`Themes available for swizzle: ${themeNames}` + }`; + return process.exit(1); +} + +async function validateThemeName({ + themeNameParam, + themeNames, +}: { + themeNameParam: string; + themeNames: string[]; +}): Promise { + const isValidName = themeNames.includes(themeNameParam); + if (!isValidName) { + return handleInvalidThemeName({ + themeNameParam, + themeNames, + }); + } + return themeNameParam; +} + +export async function getThemeName({ + themeNameParam, + themeNames, + list, +}: { + themeNameParam: string | undefined; + themeNames: string[]; + list: boolean | undefined; +}): Promise { + if (list && !themeNameParam) { + logger.info`Themes available for swizzle: name=${themeNames}`; + return process.exit(0); + } + return themeNameParam + ? validateThemeName({themeNameParam, themeNames}) + : askThemeName(themeNames); +} + +export function getThemePath({ + plugins, + themeName, + typescript, +}: { + plugins: SwizzlePlugin[]; + themeName: string; + typescript: boolean | undefined; +}): string { + const pluginInstance = getPluginByThemeName(plugins, themeName); + const themePath = typescript + ? pluginInstance.instance.getTypeScriptThemePath?.() + : pluginInstance.instance.getThemePath?.(); + if (!themePath) { + logger.warn( + typescript + ? logger.interpolate`name=${themeName} does not provide TypeScript theme code via ${'getTypeScriptThemePath()'}.` + : // This is... technically possible to happen, e.g. returning undefined + // from getThemePath. Plugins may intentionally or unintentionally + // disguise as themes? + logger.interpolate`name=${themeName} does not provide any theme code.`, + ); + return process.exit(1); + } + return themePath; +} diff --git a/packages/docusaurus/src/server/plugins/init.ts b/packages/docusaurus/src/server/plugins/init.ts index bca734d302..a6d6e04459 100644 --- a/packages/docusaurus/src/server/plugins/init.ts +++ b/packages/docusaurus/src/server/plugins/init.ts @@ -24,7 +24,7 @@ import { normalizeThemeConfig, } from '@docusaurus/utils-validation'; -type NormalizedPluginConfig = { +export type NormalizedPluginConfig = { plugin: PluginModule; options: PluginOptions; // Only available when a string is provided in config @@ -96,6 +96,17 @@ async function normalizePluginConfig( ); } +export async function normalizePluginConfigs( + pluginConfigs: PluginConfig[], + pluginRequire: NodeRequire, +): Promise { + return Promise.all( + pluginConfigs.map((pluginConfig) => + normalizePluginConfig(pluginConfig, pluginRequire), + ), + ); +} + function getOptionValidationFunction( normalizedPluginConfig: NormalizedPluginConfig, ): PluginModule['validateOptions'] { @@ -132,6 +143,10 @@ export default async function initPlugins({ // We need to resolve plugins from the perspective of the siteDir, since the // siteDir's package.json declares the dependency on these plugins. const pluginRequire = createRequire(context.siteConfigPath); + const pluginConfigsNormalized = await normalizePluginConfigs( + pluginConfigs, + pluginRequire, + ); async function doGetPluginVersion( normalizedPluginConfig: NormalizedPluginConfig, @@ -180,12 +195,8 @@ export default async function initPlugins({ } async function initializePlugin( - pluginConfig: PluginConfig, + normalizedPluginConfig: NormalizedPluginConfig, ): Promise { - const normalizedPluginConfig = await normalizePluginConfig( - pluginConfig, - pluginRequire, - ); const pluginVersion: DocusaurusPluginVersionInformation = await doGetPluginVersion(normalizedPluginConfig); const pluginOptions = doValidatePluginOptions(normalizedPluginConfig); @@ -210,7 +221,7 @@ export default async function initPlugins({ const plugins: InitializedPlugin[] = ( await Promise.all( - pluginConfigs.map((pluginConfig) => { + pluginConfigsNormalized.map((pluginConfig) => { if (!pluginConfig) { return null; } diff --git a/project-words.txt b/project-words.txt index 3445c1d9ea..cccb6f5022 100644 --- a/project-words.txt +++ b/project-words.txt @@ -267,6 +267,7 @@ treeify treosh triaging typecheck +typechecks typesense unflat unist diff --git a/website/_dogfooding/dogfooding.config.js b/website/_dogfooding/dogfooding.config.js index 968d90e385..5b58c9aaa9 100644 --- a/website/_dogfooding/dogfooding.config.js +++ b/website/_dogfooding/dogfooding.config.js @@ -6,6 +6,20 @@ */ const fs = require('fs'); +const path = require('path'); + +/** @type {import('@docusaurus/types').PluginConfig[]} */ +const dogfoodingThemeInstances = [ + /** @type {import('@docusaurus/types').PluginModule} */ + function swizzleThemeTests() { + return { + name: 'swizzle-theme-tests', + getThemePath: () => + path.join(__dirname, '_swizzle_theme_tests/src/theme'), + }; + }, +]; +exports.dogfoodingThemeInstances = dogfoodingThemeInstances; /** @type {import('@docusaurus/types').PluginConfig[]} */ const dogfoodingPluginInstances = [ diff --git a/website/_dogfooding/testSwizzleThemeClassic.mjs b/website/_dogfooding/testSwizzleThemeClassic.mjs new file mode 100644 index 0000000000..44fbe20bc5 --- /dev/null +++ b/website/_dogfooding/testSwizzleThemeClassic.mjs @@ -0,0 +1,157 @@ +/** + * 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 path from 'path'; +import fs from 'fs-extra'; +import {fileURLToPath} from 'url'; +import logger from '@docusaurus/logger'; + +import ClassicTheme from '@docusaurus/theme-classic'; + +// Unsafe imports +import {readComponentNames} from '@docusaurus/core/lib/commands/swizzle/components.js'; +import {normalizeSwizzleConfig} from '@docusaurus/core/lib/commands/swizzle/config.js'; +import {wrap, eject} from '@docusaurus/core/lib/commands/swizzle/actions.js'; + +const swizzleConfig = normalizeSwizzleConfig(ClassicTheme.getSwizzleConfig()); + +const action = process.env.SWIZZLE_ACTION ?? 'eject'; +const typescript = process.env.SWIZZLE_TYPESCRIPT === 'true'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +const classicThemePathBase = path.join( + dirname, + '../../packages/docusaurus-theme-classic', +); + +const themePath = swizzleConfig + ? path.join(classicThemePathBase, 'src/theme') + : path.join(classicThemePathBase, 'lib-next/theme'); + +const toPath = path.join(dirname, '_swizzle_theme_tests'); + +console.log('\n'); +console.log('Swizzle test script'); +console.log('Args', { + action, + typescript, + dirname, + themePath, + toPath, + swizzleConfig, +}); +console.log('\n'); + +await fs.remove(toPath); + +let componentNames = await readComponentNames(themePath); + +const componentsNotFound = Object.keys(swizzleConfig.components).filter( + (componentName) => !componentNames.includes(componentName), +); +if (componentsNotFound.length > 0) { + logger.error( + `${ + componentsNotFound.length + } components exist in the swizzle config but do not exist in practice. +Please double-check or clean up these components from the config: +- ${componentsNotFound.join('\n- ')} +`, + ); + process.exit(1); +} + +// TODO temp workaround: non-comps should be forbidden to wrap +if (action === 'wrap') { + const WrapBlacklist = [ + 'Layout', // due to theme-fallback? + ]; + + componentNames = componentNames.filter((componentName) => { + const blacklisted = WrapBlacklist.includes(componentName); + if (!WrapBlacklist) { + logger.warn(`${componentName} is blacklisted and will not be wrapped`); + } + return !blacklisted; + }); +} + +function getActionStatus(componentName) { + const actionStatus = + swizzleConfig.components[componentName]?.actions[action] ?? 'unsafe'; + if (!actionStatus) { + throw new Error( + `Unexpected: missing action ${action} for ${componentName}`, + ); + } + return actionStatus; +} + +for (const componentName of componentNames) { + const executeAction = () => { + const baseParams = { + action, + siteDir: toPath, + themePath, + componentName, + }; + switch (action) { + case 'wrap': + return wrap({ + ...baseParams, + importType: 'init', // For these tests, "theme-original" imports are causing an expected infinite loop + typescript, + }); + case 'eject': + return eject(baseParams); + default: + throw new Error(`Unknown action: ${action}`); + } + }; + + const actionStatus = getActionStatus(componentName); + + if (actionStatus === 'forbidden') { + logger.warn( + `${componentName} is marked as forbidden for action ${action} => skipping`, + ); + // eslint-disable-next-line no-continue + continue; + } + + const result = await executeAction(); + + const safetyLog = + actionStatus === 'unsafe' ? logger.red('unsafe') : logger.green('safe'); + + console.log( + `${componentName} ${action} (${safetyLog}) => ${ + result.createdFiles.length + } file${result.createdFiles.length > 1 ? 's' : ''} written`, + ); +} + +logger.success(` +End of the Swizzle test script. +Now try to build the site and see if it works! +`); + +const componentsWithMissingConfigs = componentNames.filter( + (componentName) => !swizzleConfig.components[componentName], +); + +// TODO require theme exhaustive config, fail fast? +// (at least for our classic theme?) +// TODO provide util so that theme authors can also check exhaustiveness? +if (componentsWithMissingConfigs.length > 0) { + logger.warn( + `${componentsWithMissingConfigs.length} components have no swizzle config. +Sample: ${componentsWithMissingConfigs.slice(0, 5).join(', ')} ... +`, + ); +} diff --git a/website/docs/_partials/swizzleWarning.mdx b/website/docs/_partials/swizzleWarning.mdx deleted file mode 100644 index e2aec479dd..0000000000 --- a/website/docs/_partials/swizzleWarning.mdx +++ /dev/null @@ -1,5 +0,0 @@ -:::caution - -We discourage swizzling of components during the Docusaurus 2 beta phase. The theme components APIs are likely to evolve and have breaking changes. If possible, stick with the default appearance for now. - -::: diff --git a/website/docs/advanced/client.md b/website/docs/advanced/client.md new file mode 100644 index 0000000000..4148f27031 --- /dev/null +++ b/website/docs/advanced/client.md @@ -0,0 +1,77 @@ +# Client architecture + +## Theme aliases {#theme-aliases} + +A theme works by exporting a set of components, e.g. `Navbar`, `Layout`, `Footer`, to render the data passed down from plugins. Docusaurus and users use these components by importing them using the `@theme` webpack alias: + +```js +import Navbar from '@theme/Navbar'; +``` + +The alias `@theme` can refer to a few directories, in the following priority: + +1. A user's `website/src/theme` directory, which is a special directory that has the higher precedence. +2. A Docusaurus theme package's `theme` directory. +3. Fallback components provided by Docusaurus core (usually not needed). + +This is called a _layered architecture_: a higher-priority layer providing the component would shadow a lower-priority layer, making swizzling possible. Given the following structure: + +``` +website +├── node_modules +│ └── @docusaurus/theme-classic +│ └── theme +│ └── Navbar.js +└── src + └── theme + └── Navbar.js +``` + +`website/src/theme/Navbar.js` takes precedence whenever `@theme/Navbar` is imported. This behavior is called component swizzling. If you are familiar with Objective C where a function's implementation can be swapped during runtime, it's the exact same concept here with changing the target `@theme/Navbar` is pointing to! + +We already talked about how the "userland theme" in `src/theme` can re-use a theme component through the [`@theme-original`](#wrapping) alias. One theme package can also wrap a component from another theme, by importing the component from the initial theme, using the `@theme-init` import. + +Here's an example of using this feature to enhance the default theme `CodeBlock` component with a `react-live` playground feature. + +```js +import InitialCodeBlock from '@theme-init/CodeBlock'; +import React from 'react'; + +export default function CodeBlock(props) { + return props.live ? ( + + ) : ( + + ); +} +``` + +Check the code of `@docusaurus/theme-live-codeblock` for details. + +:::caution + +Unless you want to publish a re-usable "theme enhancer" (like `@docusaurus/theme-live-codeblock`), you likely don't need `@theme-init`. + +::: + +It can be quite hard to wrap your mind around these aliases. Let's imagine the following case with a super convoluted setup with three themes/plugins and the site itself all trying to define the same component. Internally, Docusaurus loads these themes as a "stack". + +```text ++-------------------------------------------------+ +| `website/src/theme/CodeBlock.js` | <-- `@theme/CodeBlock` always points to the top ++-------------------------------------------------+ +| `theme-live-codeblock/theme/CodeBlock/index.js` | <-- `@theme-original/CodeBlock` points to the topmost non-swizzled component ++-------------------------------------------------+ +| `plugin-awesome-codeblock/theme/CodeBlock.js` | ++-------------------------------------------------+ +| `theme-classic/theme/CodeBlock/index.js` | <-- `@theme-init/CodeBlock` always points to the bottom ++-------------------------------------------------+ +``` + +The components in this "stack" are pushed in the order of `preset plugins > preset themes > plugins > themes > site`, so the swizzled component in `website/src/theme` always comes out on top because it's loaded last. + +`@theme/*` always points to the topmost component—when `CodeBlock` is swizzled, all other components requesting `@theme/CodeBlock` receive the swizzled version. + +`@theme-original/*` always points to the topmost non-swizzled component. That's why you can import `@theme-original/CodeBlock` in the swizzled component—it points to the next one in the "component stack", a theme-provided one. Plugin authors should not try to use this because your component could be the topmost component and cause a self-import. + +`@theme-init/*` always points to the bottommost component—usually, this comes from the theme or plugin that first provides this component. Individual plugins / themes trying to enhance code block can safely use `@theme-init/CodeBlock` to get its basic version. Site creators should generally not use this because you likely want to enhance the _topmost_ instead of the _bottommost_ component. It's also possible that the `@theme-init/CodeBlock` alias does not exist at all—Docusaurus only creates it when it points to a different one from `@theme-original/CodeBlock`, i.e. when it's provided by more than one theme. We don't waste aliases! diff --git a/website/docs/advanced/swizzling.md b/website/docs/advanced/swizzling.md deleted file mode 100644 index 7fdbf9a815..0000000000 --- a/website/docs/advanced/swizzling.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -description: Customize your site's appearance through creating your own theme components ---- - -# Swizzling - -In this section, we will introduce how customization of layout is done in Docusaurus. - -> Déja vu...? - -This section is similar to [Styling and Layout](../styling-layout.md), but this time, we are going to write more code and go deeper into the internals instead of playing with stylesheets. We will talk about a central concept in Docusaurus customization: **swizzling**, from how to swizzle, to how it works under the hood. - -We know you are busy, so we will start with the "how" before going into the "why". - -## Swizzling {#swizzling} - -```mdx-code-block -import SwizzleWarning from "../_partials/swizzleWarning.mdx" - - -``` - -Docusaurus Themes' components are designed to be replaceable. The replacing is called "swizzle". In Objective C, method swizzling is the process of changing the implementation of an existing selector (method). **In the context of a website, component swizzling means providing an alternative component that takes precedence over the component provided by the theme.** (To gain a deeper understanding of this, you have to understand [how theme components are resolved](#theme-aliases)). To help you get started, we created a command called `docusaurus swizzle`. - -### Ejecting theme components {#ejecting-theme-components} - -To eject a component provided by the theme, run the following command in your doc site: - -```bash npm2yarn -npm run swizzle [theme name] [component name] -``` - -As an example, to swizzle the `