feat(core): rework swizzle CLI (#6243)

Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Joshua Chen 2022-02-25 21:13:15 +08:00 committed by GitHub
parent d43066f6f1
commit 39b66d82ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 3633 additions and 585 deletions

View file

@ -17,3 +17,5 @@ copyUntypedFiles.mjs
packages/create-docusaurus/lib/* packages/create-docusaurus/lib/*
packages/create-docusaurus/templates/facebook/.eslintrc.js packages/create-docusaurus/templates/facebook/.eslintrc.js
website/_dogfooding/_swizzle_theme_tests

View file

@ -260,7 +260,11 @@ module.exports = {
'no-unused-vars': OFF, 'no-unused-vars': OFF,
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
ERROR, ERROR,
{argsIgnorePattern: '^_', ignoreRestSiblings: true}, {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
], ],
}, },
overrides: [ overrides: [

37
.github/workflows/tests-swizzle.yml vendored Normal file
View file

@ -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

View file

@ -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" 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 "$_" cd "$_"
echo "# hello" > test-file.md 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 - name: Docusaurus Build
run: yarn build:website --locale en run: yarn build:website:fast
- name: TypeCheck website
run: yarn workspace website typecheck

2
.gitignore vendored
View file

@ -31,6 +31,8 @@ website/changelog
!website/netlifyDeployPreview/index.html !website/netlifyDeployPreview/index.html
!website/netlifyDeployPreview/_redirects !website/netlifyDeployPreview/_redirects
website/_dogfooding/_swizzle_theme_tests
website/i18n/**/* website/i18n/**/*
#!website/i18n/fr #!website/i18n/fr
#!website/i18n/fr/**/* #!website/i18n/fr/**/*

View file

@ -20,3 +20,6 @@ website/versioned_sidebars/*.json
examples/ examples/
website/static/katex/katex.min.css website/static/katex/katex.min.css
website/changelog/_swizzle_theme_tests
website/_dogfooding/_swizzle_theme_tests

View file

@ -2,6 +2,7 @@
* *
!*/ !*/
!*.css !*.css
__tests__/
build build
coverage coverage
examples/ examples/

View file

@ -10,6 +10,7 @@ import {fileURLToPath} from 'url';
const ignorePatterns = [ const ignorePatterns = [
'/node_modules/', '/node_modules/',
'__fixtures__', '__fixtures__',
'/testUtils.ts',
'/packages/docusaurus/lib', '/packages/docusaurus/lib',
'/packages/docusaurus-utils/lib', '/packages/docusaurus-utils/lib',
'/packages/docusaurus-utils-validation/lib', '/packages/docusaurus-utils-validation/lib',

View file

@ -23,7 +23,9 @@
"build:website": "yarn workspace website build", "build:website": "yarn workspace website build",
"build:website:baseUrl": "yarn workspace website build:baseUrl", "build:website:baseUrl": "yarn workspace website build:baseUrl",
"build:website:blogOnly": "yarn workspace website build:blogOnly", "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:fast": "yarn workspace website build:fast",
"build:website:en": "yarn workspace website build --locale en", "build:website:en": "yarn workspace website build --locale en",
"clear:website": "yarn workspace website clear", "clear:website": "yarn workspace website clear",

View file

@ -120,6 +120,10 @@ function success(msg: unknown, ...values: InterpolatableValue[]): void {
); );
} }
function newLine(): void {
console.log();
}
const logger = { const logger = {
red: chalk.red, red: chalk.red,
yellow: chalk.yellow, yellow: chalk.yellow,
@ -136,6 +140,7 @@ const logger = {
warn, warn,
error, error,
success, success,
newLine,
}; };
// TODO remove when migrating to ESM // TODO remove when migrating to ESM

View file

@ -83,6 +83,7 @@ declare module '@generated/codeTranslations' {
} }
declare module '@theme-original/*'; declare module '@theme-original/*';
declare module '@theme-init/*';
declare module '@theme/Error' { declare module '@theme/Error' {
export interface Props { export interface Props {

View file

@ -26,7 +26,6 @@ export default function pluginDebug({
getThemePath() { getThemePath() {
return path.resolve(__dirname, '../lib/theme'); return path.resolve(__dirname, '../lib/theme');
}, },
getTypeScriptThemePath() { getTypeScriptThemePath() {
return path.resolve(__dirname, '../src/theme'); return path.resolve(__dirname, '../src/theme');
}, },

View file

@ -64,7 +64,10 @@ export default function pluginPWA(
name: 'docusaurus-plugin-pwa', name: 'docusaurus-plugin-pwa',
getThemePath() { getThemePath() {
return path.resolve(__dirname, './theme'); return path.resolve(__dirname, '../lib/theme');
},
getTypeScriptThemePath() {
return path.resolve(__dirname, '../src/theme');
}, },
getClientModules() { getClientModules() {

View file

@ -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',
},
},
},
};
}

View file

@ -207,21 +207,5 @@ ${announcementBar ? AnnouncementBarInlineJavaScript : ''}
}; };
} }
const swizzleAllowedComponents = [ export {default as getSwizzleConfig} from './getSwizzleConfig';
'CodeBlock',
'DocSidebar',
'Footer',
'NotFound',
'SearchBar',
'IconArrow',
'IconEdit',
'IconMenu',
'hooks/useTheme',
'prism-include-languages',
];
export function getSwizzleComponentList(): string[] {
return swizzleAllowedComponents;
}
export {validateThemeConfig} from './validateThemeConfig'; export {validateThemeConfig} from './validateThemeConfig';

View file

@ -47,11 +47,10 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin<void> {
name: 'docusaurus-theme-search-algolia', name: 'docusaurus-theme-search-algolia',
getThemePath() { getThemePath() {
return path.resolve(__dirname, './theme'); return path.resolve(__dirname, '../lib/theme');
}, },
getTypeScriptThemePath() { getTypeScriptThemePath() {
return path.resolve(__dirname, '..', 'src', 'theme'); return path.resolve(__dirname, '../src/theme');
}, },
getDefaultCodeTranslationMessages() { getDefaultCodeTranslationMessages() {

View file

@ -316,13 +316,30 @@ export type LoadedPlugin<Content = unknown> = InitializedPlugin<Content> & {
readonly content: Content; readonly content: Content;
}; };
export type SwizzleAction = 'eject' | 'wrap';
export type SwizzleActionStatus = 'safe' | 'unsafe' | 'forbidden';
export type SwizzleComponentConfig = {
actions: Record<SwizzleAction, SwizzleActionStatus>;
description?: string;
};
export type SwizzleConfig = {
components: Record<string, SwizzleComponentConfig>;
// 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 = { export type PluginModule = {
<Options, Content>(context: LoadContext, options: Options): <Options, Content>(context: LoadContext, options: Options):
| Plugin<Content> | Plugin<Content>
| Promise<Plugin<Content>>; | Promise<Plugin<Content>>;
validateOptions?: <T>(data: OptionValidationContext<T>) => T; validateOptions?: <T>(data: OptionValidationContext<T>) => T;
validateThemeConfig?: <T>(data: ThemeConfigValidationContext<T>) => T; validateThemeConfig?: <T>(data: ThemeConfigValidationContext<T>) => T;
getSwizzleComponentList?: () => string[];
getSwizzleComponentList?: () => string[] | undefined; // TODO deprecate this one later
getSwizzleConfig?: () => SwizzleConfig | undefined;
}; };
export type ImportedPluginModule = PluginModule & { export type ImportedPluginModule = PluginModule & {

View file

@ -68,20 +68,28 @@ cli
cli cli
.command('swizzle [themeName] [componentName] [siteDir]') .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( .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)', 'copy TypeScript theme files when possible (default: false)',
) )
.option('--danger', 'enable swizzle for internal component of themes') .option('--danger', 'enable swizzle for unsafe component of themes')
.action(async (themeName, componentName, siteDir, {typescript, danger}) => { .action(async (themeName, componentName, siteDir, options) => {
swizzle( swizzle(await resolveDir(siteDir), themeName, componentName, options);
await resolveDir(siteDir),
themeName,
componentName,
typescript,
danger,
);
}); });
cli cli

View file

@ -56,6 +56,7 @@
"boxen": "^6.2.1", "boxen": "^6.2.1",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"clean-css": "^5.2.4", "clean-css": "^5.2.4",
"cli-table3": "^0.6.1",
"combine-promises": "^1.1.0", "combine-promises": "^1.1.0",
"commander": "^5.1.0", "commander": "^5.1.0",
"copy-webpack-plugin": "^10.2.4", "copy-webpack-plugin": "^10.2.4",
@ -117,7 +118,8 @@
"@types/wait-on": "^5.3.1", "@types/wait-on": "^5.3.1",
"@types/webpack-bundle-analyzer": "^4.4.1", "@types/webpack-bundle-analyzer": "^4.4.1",
"react-test-renderer": "^17.0.2", "react-test-renderer": "^17.0.2",
"tmp-promise": "^3.0.3" "tmp-promise": "^3.0.3",
"tree-node-cli": "^1.5.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.4 || ^17.0.0", "react": "^16.8.4 || ^17.0.0",

View file

@ -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<string> {
let results: Array<string> = [];
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<string> {
// 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<string> {
// 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<void> {
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 <fromPath> does not exist, we try to swizzle
// <fromPath>.(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)}.`;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {};

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function ComponentInSubFolder() {
return <div>ComponentInSubFolder</div>;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {}

View file

@ -0,0 +1,2 @@
// fake test file
export {}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function Sibling() {
return <div>Sibling</div>;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {};

View file

@ -0,0 +1,2 @@
// fake test file
export {}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function ComponentInFolder() {
return <div>ComponentInFolder</div>;
}

View file

@ -0,0 +1,3 @@
.testClass {
background: black;
}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {}

View file

@ -0,0 +1,2 @@
// fake test file
export {}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function FirstLevelComponent() {
return <div>First level component</div>;
}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {}

View file

@ -0,0 +1,2 @@
// fake storybook file
export {}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function ComponentInFixturesFolder() {
return <div>ComponentInFixturesFolder</div>;
}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function ComponentInMocksFolder() {
return <div>ComponentInMocksFolder</div>;
}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function ComponentInTestFolder() {
return <div>ComponentInTestFolder</div>;
}

View file

@ -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 <div>Sibling</div>;
}
"
`;
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 <div>ComponentInFolder</div>;
}
"
`;
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 <div>Sibling</div>;
}
"
`;
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 <div>ComponentInFolder</div>;
}
"
`;
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 <div>ComponentInSubFolder</div>;
}
"
`;
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 <div>ComponentInSubFolder</div>;
}
"
`;
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 <div>Sibling</div>;
}
"
`;
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 <div>Sibling</div>;
}
"
`;
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 <div>First level component</div>;
}
"
`;
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 <div>First level component</div>;
}
"
`;
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 (
<>
<ComponentInFolder {...props} />
</>
);
}
"
`;
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<typeof ComponentInFolderType>
export default function ComponentInFolderWrapper(props: Props): JSX.Element {
return (
<>
<ComponentInFolder {...props} />
</>
);
}
"
`;
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 (
<>
<ComponentInSubFolder {...props} />
</>
);
}
"
`;
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<typeof ComponentInSubFolderType>
export default function ComponentInSubFolderWrapper(props: Props): JSX.Element {
return (
<>
<ComponentInSubFolder {...props} />
</>
);
}
"
`;
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 (
<>
<Sibling {...props} />
</>
);
}
"
`;
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<typeof SiblingType>
export default function SiblingWrapper(props: Props): JSX.Element {
return (
<>
<Sibling {...props} />
</>
);
}
"
`;
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 (
<>
<FirstLevelComponent {...props} />
</>
);
}
"
`;
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<typeof FirstLevelComponentType>
export default function FirstLevelComponentWrapper(props: Props): JSX.Element {
return (
<>
<FirstLevelComponent {...props} />
</>
);
}
"
`;
exports[`swizzle wrap FirstLevelComponent TS: theme dir tree 1`] = `
"theme
└── FirstLevelComponent.tsx"
`;

View file

@ -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 (
<>
<FirstLevelComponent {...props} />
</>
);
}
"
`);
});
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 (
<>
<ComponentInSubFolder {...props} />
</>
);
}
"
`);
});
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 (
<>
<ComponentInFolder {...props} />
</>
);
}
"
`);
});
});
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<typeof FirstLevelComponentType>
export default function FirstLevelComponentWrapper(props: Props): JSX.Element {
return (
<>
<FirstLevelComponent {...props} />
</>
);
}
"
`);
});
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<typeof ComponentInSubFolderType>
export default function ComponentInSubFolderWrapper(props: Props): JSX.Element {
return (
<>
<ComponentInSubFolder {...props} />
</>
);
}
"
`);
});
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<typeof ComponentInFolderType>
export default function ComponentInFolderWrapper(props: Props): JSX.Element {
return (
<>
<ComponentInFolder {...props} />
</>
);
}
"
`);
});
});
});

View file

@ -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);
});
});

View file

@ -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]"`,
);
});
});

View file

@ -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();
});
});

View file

@ -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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'docusaurus-test-swizzle-sitedir'));
}

View file

@ -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<SwizzleOptions, 'wrap' | 'eject'>,
): Promise<SwizzleAction> {
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<boolean> {
return (
(await fs.pathExists(dirPath)) && (await fs.stat(dirPath)).isDirectory()
);
}
export async function eject({
siteDir,
themePath,
componentName,
}: ActionParams): Promise<ActionResult> {
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<ActionResult> {
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<typeof ${componentName}Type>
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]};
}

View file

@ -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>,
): 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);
}

View file

@ -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<string[]> {
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<ComponentFile[]> {
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
/(?<!\.d)\.[jt]sx?$/.test(file.fullPath) &&
!/(?<!\.d)\.(?:test|tests|story|stories)\.[jt]sx?$/.test(
file.fullPath,
)
) {
const componentName = formatComponentName(
posixPath(path.relative(themePath, file.fullPath)),
);
return [{...file, componentName}];
}
return [];
}),
)
).flat();
}
const componentFiles = await walk(themePath);
const componentFilesOrdered = _.orderBy(
componentFiles,
[(f) => 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<ThemeComponents> {
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<string> {
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<string> {
if (list) {
logger.info(listComponentNames(themeComponents));
return process.exit(0);
}
const componentName: string = componentNameParam
? await handleComponentNameParam({
componentNameParam,
themeComponents,
})
: await askComponentName(themeComponents);
return componentName;
}

View file

@ -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<SwizzleConfig>({
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;
}

View file

@ -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<SwizzleContext> {
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,
})),
};
}

View file

@ -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<void> {
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<SwizzleOptions>,
): Promise<void> {
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<ActionResult> {
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);
}

View file

@ -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<string> {
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<string> {
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<boolean> {
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<SwizzleAction> {
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;
}

View file

@ -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()}
`;
}

View file

@ -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<string> {
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<string> {
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;
}

View file

@ -24,7 +24,7 @@ import {
normalizeThemeConfig, normalizeThemeConfig,
} from '@docusaurus/utils-validation'; } from '@docusaurus/utils-validation';
type NormalizedPluginConfig = { export type NormalizedPluginConfig = {
plugin: PluginModule; plugin: PluginModule;
options: PluginOptions; options: PluginOptions;
// Only available when a string is provided in config // 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<NormalizedPluginConfig[]> {
return Promise.all(
pluginConfigs.map((pluginConfig) =>
normalizePluginConfig(pluginConfig, pluginRequire),
),
);
}
function getOptionValidationFunction( function getOptionValidationFunction(
normalizedPluginConfig: NormalizedPluginConfig, normalizedPluginConfig: NormalizedPluginConfig,
): PluginModule['validateOptions'] { ): PluginModule['validateOptions'] {
@ -132,6 +143,10 @@ export default async function initPlugins({
// We need to resolve plugins from the perspective of the siteDir, since the // We need to resolve plugins from the perspective of the siteDir, since the
// siteDir's package.json declares the dependency on these plugins. // siteDir's package.json declares the dependency on these plugins.
const pluginRequire = createRequire(context.siteConfigPath); const pluginRequire = createRequire(context.siteConfigPath);
const pluginConfigsNormalized = await normalizePluginConfigs(
pluginConfigs,
pluginRequire,
);
async function doGetPluginVersion( async function doGetPluginVersion(
normalizedPluginConfig: NormalizedPluginConfig, normalizedPluginConfig: NormalizedPluginConfig,
@ -180,12 +195,8 @@ export default async function initPlugins({
} }
async function initializePlugin( async function initializePlugin(
pluginConfig: PluginConfig, normalizedPluginConfig: NormalizedPluginConfig,
): Promise<InitializedPlugin> { ): Promise<InitializedPlugin> {
const normalizedPluginConfig = await normalizePluginConfig(
pluginConfig,
pluginRequire,
);
const pluginVersion: DocusaurusPluginVersionInformation = const pluginVersion: DocusaurusPluginVersionInformation =
await doGetPluginVersion(normalizedPluginConfig); await doGetPluginVersion(normalizedPluginConfig);
const pluginOptions = doValidatePluginOptions(normalizedPluginConfig); const pluginOptions = doValidatePluginOptions(normalizedPluginConfig);
@ -210,7 +221,7 @@ export default async function initPlugins({
const plugins: InitializedPlugin[] = ( const plugins: InitializedPlugin[] = (
await Promise.all( await Promise.all(
pluginConfigs.map((pluginConfig) => { pluginConfigsNormalized.map((pluginConfig) => {
if (!pluginConfig) { if (!pluginConfig) {
return null; return null;
} }

View file

@ -267,6 +267,7 @@ treeify
treosh treosh
triaging triaging
typecheck typecheck
typechecks
typesense typesense
unflat unflat
unist unist

View file

@ -6,6 +6,20 @@
*/ */
const fs = require('fs'); 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[]} */ /** @type {import('@docusaurus/types').PluginConfig[]} */
const dogfoodingPluginInstances = [ const dogfoodingPluginInstances = [

View file

@ -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(', ')} ...
`,
);
}

View file

@ -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.
:::

View file

@ -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 ? (
<ReactLivePlayground {...props} />
) : (
<InitialCodeBlock {...props} />
);
}
```
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!

View file

@ -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"
<SwizzleWarning/>
```
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 `<Footer />` component in `@docusaurus/theme-classic` for your site, run:
```bash npm2yarn
npm run swizzle @docusaurus/theme-classic Footer
```
This will copy the current `<Footer />` component used by Docusaurus to an `src/theme/Footer` directory under the root of your site, which is where Docusaurus will look for swizzled components. Docusaurus will then use the swizzled component in place of the original one from the theme.
:::note
You need to restart your webpack dev server in order for Docusaurus to know about the new component.
:::
If you run `swizzle` without `component name` or `theme name`, the command will give you a list to choose from. To only list available components, run with the `--list` option:
```bash npm2yarn
npm run swizzle @docusaurus/theme-classic --list
```
"Swizzle" is a central concept in Docusaurus, and is a natural product of our [layered theme architecture](#theme-aliases). Note that the command `docusaurus swizzle` is only an automated way to help you swizzle the component: you can still do it manually by creating the `src/theme/Footer.js` file, and Docusaurus will pick that one up when resolving theme components. There's no internal magic behind this command!
### Wrapping theme components {#wrapping-theme-components}
Ejecting a component is risky. It means you have to maintain an almost duplicate copy of the original theme component. Also, it's likely that we will change internal implementations in future versions and break your component, even if you never touched that part of the code.
Very often, you don't need to re-implement a component from scratch, but only to render additional items before or after it, or conditionally call some other logic. In this case, you are still going to swizzle the component—but not making a self-sustained one. Instead, you can delegate most of the logic and layout to the original theme component. The `@theme-original` alias allows you to import the original theme component and wrap it as a higher-order component.
Here is an example to display some text just above the footer, with minimal code duplication.
```js title="src/theme/Footer.js"
import OriginalFooter from '@theme-original/Footer';
import React from 'react';
export default function Footer(props) {
return (
<>
<div>Before footer</div>
<OriginalFooter {...props} />
</>
);
}
```
Should you be wondering why we have to use `'@theme-original/Footer'` instead of `'@theme/Footer'`, a short explanation is that once you have the swizzled component, the `'@theme/Footer'` alias will now point to your swizzled component, and thus cause a self-import. For a more in-depth explanation, see [theme aliases](#theme-aliases).
## Which component should I swizzle? {#which-component-should-i-swizzle}
Currently, `theme-classic` has about 100 components[^source]! If you want to customize a part of your site's layout, which component should you choose?
[^source]: https://github.com/facebook/docusaurus/tree/main/packages/docusaurus-theme-classic/src/theme
You can follow the following steps to locate the component to swizzle:
1. **Search.** Our components are semantically named, so you should be able to infer its function from the name. The swizzle CLI allows you to enter part of a component name to narrow down the available choices. For example, if you run `yarn swizzle @docusaurus/theme-classic`, and enter `Doc`, only the docs-related components will be listed.
2. **Start with a higher-level component.** Components form a tree with some components importing others. Every route will be associated with one top-level component that the route will render (most of them listed in [Routing in content plugins](routing.md#routing-in-content-plugins)). For example, all blog post pages have `@theme/BlogPostPage` as the topmost component. You can start with swizzling this component, and then go down the component tree to locate the component that renders just what you are targeting. Don't forget to unswizzle the rest by deleting the files after you've found the correct one, so you don't maintain too many components.
3. **Read the source code and use search wisely.** Topmost components are registered by the plugin with `addRoute`, so you can search for `addRoute` and see which component the plugin references. Afterwards, read the code of all components that this component references.
4. **Ask.** If you still have no idea which component to swizzle to achieve the desired effect, you can reach out for help in one of our [support channels](/community/support).
### Wrapping your site with `<Root>` {#wrapper-your-site-with-root}
The `<Root>` component is one that you probably won't spot. Every component provided by `theme-classic` is ultimately only rendered on certain routes, and will be unmounted during route transition; however, the `<Root>` theme component is rendered at the very top of the Docusaurus SPA, above the router and the theme `<Layout>`, and will **never unmount**, allowing you to wrap your site with additional logic like global state. You can swizzle it by creating a file at `src/theme/Root.js`:
```js title="website/src/theme/Root.js"
import React from 'react';
// Default implementation, that you can customize
function Root({children}) {
return <>{children}</>;
}
export default Root;
```
:::tip
Use this component to render React Context providers and global stateful logic.
:::
## Do I need to swizzle? {#do-i-need-to-swizzle}
Swizzling ultimately means you have to maintain part of the code directly used to build your site, and you have to interact with Docusaurus internal APIs. If you can, think about the following alternatives when customizing your site:
1. **Use CSS.** CSS rules and selectors can often help you achieve a decent degree of customization. Refer to [styling and layout](../styling-layout.md) for more details.
2. **Use translations.** It may sound surprising, but translations are ultimately just a way to customize the text labels. For example, if your site's default language is `en`, you can still run `yarn write-translations -l en` and edit the `code.json` emitted. Refer to [i18n tutorial](../i18n/i18n-tutorial.md) for more details.
3. **The smaller, the better.** If swizzling is inevitable, prefer to swizzle only the relevant part and maintain as little code on your own as possible. Swizzling a small component often means less risk of breaking during upgrade. [Wrapping](#wrapping-theme-components) is also a far safer alternative to [ejecting](#ejecting-theme-components).
## 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-theme-components) 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 ? (
<ReactLivePlayground {...props} />
) : (
<InitialCodeBlock {...props} />
);
}
```
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!

View file

@ -85,44 +85,37 @@ For advanced minification of CSS bundle, we use the [advanced cssnano preset](ht
::: :::
### `docusaurus swizzle [siteDir]` {#docusaurus-swizzle-sitedir} ### `docusaurus swizzle [themeName] [componentName] [siteDir]` {#docusaurus-swizzle}
```mdx-code-block [Swizzle](./swizzling.md) a theme component to customize it.
import SwizzleWarning from "./_partials/swizzleWarning.mdx"
<SwizzleWarning/>
```
Change any Docusaurus theme components to your liking with `npm run swizzle`.
```bash npm2yarn ```bash npm2yarn
npm run swizzle [themeName] [componentName] [siteDir] npm run swizzle [themeName] [componentName] [siteDir]
# Example (leaving out the siteDir to indicate this directory) # Example (leaving out the siteDir to indicate this directory)
npm run swizzle @docusaurus/theme-classic DocSidebar npm run swizzle @docusaurus/theme-classic Footer -- --eject
``` ```
Running the command will copy the relevant theme files to your site folder. You may then make any changes to it and Docusaurus will use it instead of the one provided from the theme. The swizzle CLI is interactive and will guide you through the whole [swizzle process](./swizzling.md).
`npm run swizzle` without `themeName` lists all the themes available for swizzling; similarly, `npm run swizzle [themeName]` without `componentName` lists all the components available for swizzling. #### Options {#options-swizzle}
#### Options {#options-2} | Name | Description |
| --------------- | ---------------------------------------------------- |
| Name | Description | | `themeName` | The name of the theme to swizzle from. |
| ------------------ | -------------------------------------- | | `componentName` | The name of the theme component to swizzle. |
| `themeName` | The name of the theme you are using. | | `--list` | Display components available for swizzling |
| `swizzleComponent` | The name of the component to swizzle. | | `--eject` | [Eject](./swizzling.md#ejecting) the theme component |
| `--danger` | Allow swizzling of unstable components | | `--wrap` | [Wrap](./swizzling.md#wrapping) the theme component |
| `--typescript` | Swizzle TypeScript components | | `--danger` | Allow immediate swizzling of unsafe components |
| `--typescript` | Swizzle the TypeScript variant component |
:::caution :::caution
Unstable Components: components that have a higher risk of breaking changes due to internal refactorings. Unsafe components have a higher risk of breaking changes due to internal refactorings.
::: :::
To learn more about swizzling, see the [swizzling guide](./advanced/swizzling.md).
### `docusaurus deploy [siteDir]` {#docusaurus-deploy-sitedir} ### `docusaurus deploy [siteDir]` {#docusaurus-deploy-sitedir}
Deploys your site with [GitHub Pages](https://pages.github.com/). Check out the docs on [deployment](deployment.mdx#deploying-to-github-pages) for more details. Deploys your site with [GitHub Pages](https://pages.github.com/). Check out the docs on [deployment](deployment.mdx#deploying-to-github-pages) for more details.

View file

@ -492,7 +492,7 @@ You'll have to migrate your sidebar if it contains category type. Rename `subcat
### Footer {#footer} ### Footer {#footer}
`website/core/Footer.js` is no longer needed. If you want to modify the default footer provided by Docusaurus, [swizzle](../advanced/swizzling.md#swizzling) it: `website/core/Footer.js` is no longer needed. If you want to modify the default footer provided by Docusaurus, [swizzle](../swizzling.md) it:
```bash npm2yarn ```bash npm2yarn
npm run swizzle @docusaurus/theme-classic Footer npm run swizzle @docusaurus/theme-classic Footer

View file

@ -8,7 +8,7 @@ import ColorGenerator from '@site/src/components/ColorGenerator';
:::tip :::tip
This section is focused on styling through stylesheets. If you find yourself needing to update the DOM structure, you can refer to [swizzling](./advanced/swizzling.md#swizzling). This section is focused on styling through stylesheets. For more advanced customizations (DOM structure, React code...), refer to the [swizzling guide](./swizzling.md).
::: :::
@ -60,13 +60,19 @@ function MyComponent() {
If you want to add CSS to any element, you can open the DevTools in your browser to inspect its class names. Class names come in several kinds: If you want to add CSS to any element, you can open the DevTools in your browser to inspect its class names. Class names come in several kinds:
- **Theme class names**. These class names are listed exhaustively in [the next subsection](#theme-class-names). They don't have any default properties. You should always prioritize targeting stable class names in your custom CSS. - **Theme class names**. These class names are listed exhaustively in [the next subsection](#theme-class-names). They don't have any default properties. You should always prioritize targeting those stable class names in your custom CSS.
- **Infima class names**. These class names usually follow the [BEM convention](http://getbem.com/naming/) of `block__element--modifier`. They are usually stable but are still considered implementation details, so you should generally avoid targeting them. However, you can [modify Infima CSS variables](#styling-your-site-with-infima). - **Infima class names**. These class names are found in the classic theme and usually follow the [BEM convention](http://getbem.com/naming/) of `block__element--modifier`. They are usually stable but are still considered implementation details, so you should generally avoid targeting them. However, you can [modify Infima CSS variables](#styling-your-site-with-infima).
- **CSS module class names**. These class names have a hash in production (`codeBlockContainer_RIuc`) and are appended with a long file path in development. They are considered implementation details and you should almost always avoid targeting them in your custom CSS. If you must, you can use an [attribute selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) (`[class*='codeBlockContainer']`) that ignores the hash. - **CSS module class names**. These class names have a hash in production (`codeBlockContainer_RIuc`) and are appended with a long file path in development. They are considered implementation details and you should almost always avoid targeting them in your custom CSS. If you must, you can use an [attribute selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) (`[class*='codeBlockContainer']`) that ignores the hash.
### Theme Class Names {#theme-class-names} ### Theme Class Names {#theme-class-names}
We provide some predefined CSS class names for global layout styling. These names are theme-agnostic and meant to be targeted by custom CSS. We provide some stable CSS class names for robust and maintainable global layout styling. These names are theme-agnostic and meant to be targeted by custom CSS.
:::tip
If you can't find a way to create a robust CSS selector, please [report your customization use-case](https://github.com/facebook/docusaurus/discussions/5468) and we will consider adding new class names.
:::
<details> <details>

324
website/docs/swizzling.md Normal file
View file

@ -0,0 +1,324 @@
---
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 will write actual React code instead of playing with stylesheets. We will talk about a central concept in Docusaurus: **swizzling**, which allows **deeper site customizations**.
In practice, swizzling permits to **swap a theme component with your own implementation**, and it comes in 2 patterns:
- [**Ejecting**](#ejecting): creates a **copy** of the original theme component, which you can fully **customize**
- [**Wrapping**](#wrapping): creates a **wrapper** around the original theme component, which you can **enhance**
<details>
<summary>Why is it called swizzling?</summary>
**The name comes from Objective-C and Swift-UI**: [method swizzling](https://pspdfkit.com/blog/2019/swizzling-in-swift/) is the process of changing the implementation of an existing selector (method).
**For Docusaurus, component swizzling means providing an alternative component that takes precedence over the component provided by the theme.**
You can think of it as [Monkey Patching](https://en.wikipedia.org/wiki/Monkey_patch) for React components, enabling you to override the default implementation. Gatsby has a similar concept called [theme shadowing](https://www.gatsbyjs.com/docs/how-to/plugins-and-themes/shadowing/).
To gain a deeper understanding of this, you have to understand [how theme components are resolved](./advanced/client.md#theme-aliases).
</details>
## Swizzling Process
### Overview
Docusaurus provides an convenient **interactive CLI** to swizzle components. You generally only need to remember the following command:
```bash npm2yarn
npm run swizzle
```
It will generate a new component your `src/theme` directory, which should look like this example:
````mdx-code-block
<Tabs>
<TabItem value="Ejecting">
```jsx title="src/theme/SomeComponent.js"
import React from 'react';
export default function SomeComponent(props) {
// You can fully customize this implementation
// including changing the JSX, CSS and React hooks
return (
<div className="some-class">
<h1>Some Component</h1>
<p>Some component implementation details</p>
</div>
);
}
```
</TabItem>
<TabItem value="Wrapping">
```jsx title="src/theme/SomeComponent.js"
import React from 'react';
import SomeComponent from '@theme-original/SomeComponent';
export default function SomeComponentWrapper(props) {
// You can enhance the original component,
// including adding extra props or JSX elements around it
return (
<>
<SomeComponent {...props} />
</>
);
}
```
</TabItem>
</Tabs>
````
To get an overview of all the themes and components available to swizzle, run:
```bash npm2yarn
npm run swizzle -- --list
```
Use `--help` to see all available CLI options, or refer to the reference [swizzle CLI documentation](./cli.md#docusaurus-swizzle).
:::note
After swizzling a component, **restart your dev server** in order for Docusaurus to know about the new component.
:::
:::warning Prefer staying on the safe side
Be sure to understand [which components are **safe to swizzle**](#what-is-safe-to-swizzle). Some components are **internal implementation details** of a theme.
:::
:::info
`docusaurus swizzle` is only an automated way to help you swizzle the component. You can also create the `src/theme/SomeComponent.js` file manually, and Docusaurus will [resolve it](./advanced/client.md#theme-aliases). There's no internal magic behind this command!
:::
### Ejecting {#ejecting}
Ejecting a theme component is the process of **creating a copy** of the original theme component, which you can **fully customize and override**.
To eject a theme component, use the swizzle CLI interactively, or with the `--eject` option:
```bash npm2yarn
npm run swizzle [theme name] [component name] -- --eject
```
An example:
```bash npm2yarn
npm run swizzle @docusaurus/theme-classic Footer -- --eject
```
This will copy the current `<Footer />` component's implementation to your site's `src/theme` directory. Docusaurus will now use this `<Footer>` component copy instead of the original one. You are now free to completely re-implement the `<Footer>` component.
```jsx title="src/theme/SomeComponent.js"
import React from 'react';
export default function Footer(props) {
return (
<footer>
<h1>This is my custom site footer</h1>
<p>And it is very different from the original</p>
</footer>
);
}
```
:::caution
Ejecting an [**unsafe**](#what-is-safe-to-swizzle) component can sometimes lead to copying a large amount of internal code, which you now have to maintain yourself. It can make Docusaurus upgrades more difficult, as you will need to migrate your customizations if the props received or internal theme APIs used have changed.
**Prefer [wrapping](#wrapping) whenever possible**: the amount of code to maintain is smaller.
:::
:::tip Re-swizzling
To keep ejected components up-to-date after a Docusaurus upgrade, re-run the eject command and compare the changes with `git diff`. You are also recommended to write a brief comment at the top of the file explaining what changes you have made, so that you could more easily re-apply your changes after re-ejection.
:::
### Wrapping {#wrapping}
Wrapping a theme component is the process of **creating a wrapper** around the original theme component, which you can **enhance**.
To wrap a theme component, use the swizzle CLI interactively, or with the `--wrap` option:
```bash npm2yarn
npm run swizzle [theme name] [component name] -- --wrap
```
An example:
```bash npm2yarn
npm run swizzle @docusaurus/theme-classic Footer -- --wrap
```
This will create a wrapper in your site's `src/theme` directory. Docusaurus will now use the `<FooterWrapper>` component instead of the original one. You can now add customizations around the original component.
```jsx title="src/theme/SomeComponent.js"
import React from 'react';
import Footer from '@theme-original/Footer';
export default function FooterWrapper(props) {
return (
<>
<section>
<h2>Extra section</h2>
<p>This is an extra section that appears above the original footer</p>
</section>
<Footer {...props} />
</>
);
}
```
<details>
<summary>What is this <code>@theme-original</code> thing?</summary>
Docusaurus uses [theme aliases](./advanced/client.md#theme-aliases) to resolve the theme components to use. The newly created wrapper takes the `@theme/SomeComponent` alias. `@theme-original/SomeComponent` permits to import original component that the wrapper shadows without creating an infinite import loop where the wrapper imports itself.
</details>
:::tip
Wrapping a theme is a great way to **add extra components around existing one** without [ejecting](#ejecting) it. For example, you can easily add a custom comment system under each blog post:
```jsx title="src/theme/BlogPostItem.js"
import React from 'react';
import BlogPostItem from '@theme-original/BlogPostItem';
import MyCustomCommentSystem from '@site/src/MyCustomCommentSystem';
export default function BlogPostItemWrapper(props) {
return (
<>
<BlogPostItem {...props} />
<MyCustomCommentSystem />
</>
);
}
```
:::
## What is safe to swizzle? {#what-is-safe-to-swizzle}
> With great power comes great responsibility
Some theme components are **internal implementation details** of a theme. Docusaurus allows you to swizzle them, but it **might be risky**.
<details>
<summary>Why is it risky?</summary>
Theme authors (including us) might have to update their theme over time: changing the component props, name, file system location, types... For example, consider a component that receives two props `name` and `age`, but after a refactor, it now receives a `person` prop with the above two properties. Your component, which still expects these two props, will render `undefined` instead.
Moreover, internal components may simply disappear. If a component is called `Sidebar` and it's later renamed to `DocSidebar`, your swizzled component will be completely ignored.
**Theme components marked as unsafe may change in a backward-incompatible way between theme minor versions.** When upgrading a theme (or Docusaurus), your customizations might **behave unexpectedly**, and can even **break your site**.
</details>
For each theme component, the swizzle CLI will indicate **3 different levels of safety** declared by theme authors:
- **Safe**: this component is safe to be swizzled, its public API is considered stable, and no breaking changes should happen within a theme **major version**
- **Unsafe**: this component is a theme implementation detail, not safe to be swizzled, and breaking changes might happen withing a theme **minor version**
- **Forbidden**: the swizzle CLI will prevent you from swizzling this component, because it is not designed to be swizzled at all
:::note
Some components might be safe to wrap, but not safe to eject.
:::
:::info
Don't be too **afraid to swizzle unsafe components**: just keep in mind that **breaking changes** might happen, and you might need to upgrade your customizations manually on minor version upgrades.
:::
:::note Report your use-case
If you have a **strong use-case for swizzling an unsafe component**, please [**report it here**](https://github.com/facebook/docusaurus/discussions/5468) and we will work together to find a solution to make it safe.
:::
## Which component should I swizzle? {#which-component-should-i-swizzle}
It is not always clear which component you should swizzle exactly to achieve the desired result. `@docusaurus/theme-classic`, which provides most of the theme components, has about [100 components](https://github.com/facebook/docusaurus/tree/main/packages/docusaurus-theme-classic/src/theme)!
:::tip
To print an overview of all the `@docusaurus/theme-classic` components:
```bash npm2yarn
npm run swizzle @docusaurus/theme-classic -- --list
```
:::
You can follow these steps to locate the appropriate component to swizzle:
1. **Component description.** Some components provide a short description, which is a good way to find the right one.
2. **Component name.** Official theme components are semantically named, so you should be able to infer its function from the name. The swizzle CLI allows you to enter part of a component name to narrow down the available choices. For example, if you run `yarn swizzle @docusaurus/theme-classic`, and enter `Doc`, only the docs-related components will be listed.
3. **Start with a higher-level component.** Components form a tree with some components importing others. Every route will be associated with one top-level component that the route will render (most of them listed in [Routing in content plugins](./advanced/routing.md#routing-in-content-plugins)). For example, all blog post pages have `@theme/BlogPostPage` as the topmost component. You can start with swizzling this component, and then go down the component tree to locate the component that renders just what you are targeting. Don't forget to unswizzle the rest by deleting the files after you've found the correct one, so you don't maintain too many components.
4. **Read the [theme source code](https://github.com/facebook/docusaurus/tree/main/packages/docusaurus-theme-classic/src/theme)** and use search wisely.
:::tip Just ask!
If you still have no idea which component to swizzle to achieve the desired effect, you can reach out for help in one of our [support channels](/community/support).
We also want to understand better your fanciest customization use-cases, so please [**report them**](https://github.com/facebook/docusaurus/discussions/5468).
:::
## Do I need to swizzle? {#do-i-need-to-swizzle}
Swizzling ultimately means you have to maintain some additional React code that interact with Docusaurus internal APIs. If you can, think about the following alternatives when customizing your site:
1. **Use CSS.** CSS rules and selectors can often help you achieve a decent degree of customization. Refer to [styling and layout](./styling-layout.md) for more details.
2. **Use translations.** It may sound surprising, but translations are ultimately just a way to customize the text labels. For example, if your site's default language is `en`, you can still run `yarn write-translations -l en` and edit the `code.json` emitted. Refer to the [i18n tutorial](./i18n/i18n-tutorial.md) for more details.
:::tip
**The smaller, the better.** If swizzling is inevitable, prefer to swizzle only the relevant part and maintain as little code on your own as possible. Swizzling a small component often means less risk of **breaking changes** during upgrade.
[Wrapping](#wrapping) is also a far safer alternative to [ejecting](#ejecting).
:::
## Wrapping your site with `<Root>` {#wrapper-your-site-with-root}
The `<Root>` component is rendered at the **very top** of the React tree, above the theme `<Layout>`, and **never unmounts**. It is the perfect place to add stateful logic that should not be re-initialized across navigations (user authentication status, shopping card state...).
Swizzle it **manually** by creating a file at `src/theme/Root.js`:
```js title="src/theme/Root.js"
import React from 'react';
// Default implementation, that you can customize
export default function Root({children}) {
return <>{children}</>;
}
```
:::tip
Use this component to render React Context providers.
:::

View file

@ -114,7 +114,7 @@ At most one plugin instance can be the "default plugin instance", by omitting th
## Using themes {#using-themes} ## Using themes {#using-themes}
Themes are loaded in the exact same way as plugins—the line between them is blurry. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./advanced/swizzling.md#theme-aliases). Themes are loaded in the exact same way as plugins—the line between them is blurry. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./swizzling.md#theme-aliases).
:::tip :::tip

View file

@ -10,7 +10,10 @@ const path = require('path');
const versions = require('./versions.json'); const versions = require('./versions.json');
const math = require('remark-math'); const math = require('remark-math');
const VersionsArchived = require('./versionsArchived.json'); const VersionsArchived = require('./versionsArchived.json');
const {dogfoodingPluginInstances} = require('./_dogfooding/dogfooding.config'); const {
dogfoodingPluginInstances,
dogfoodingThemeInstances,
} = require('./_dogfooding/dogfooding.config');
const npm2yarn = require('@docusaurus/remark-plugin-npm2yarn'); const npm2yarn = require('@docusaurus/remark-plugin-npm2yarn');
const ArchivedVersionsDropdownItems = Object.entries(VersionsArchived).splice( const ArchivedVersionsDropdownItems = Object.entries(VersionsArchived).splice(
@ -109,7 +112,7 @@ const config = {
'static', 'static',
path.join(__dirname, '_dogfooding/_asset-tests'), path.join(__dirname, '_dogfooding/_asset-tests'),
], ],
themes: ['live-codeblock'], themes: ['live-codeblock', ...dogfoodingThemeInstances],
plugins: [ plugins: [
[ [
require.resolve('./src/plugins/changelog/index.js'), require.resolve('./src/plugins/changelog/index.js'),

View file

@ -11,6 +11,10 @@
"clear": "docusaurus clear", "clear": "docusaurus clear",
"serve": "docusaurus serve", "serve": "docusaurus serve",
"test:css-order": "node testCSSOrder.mjs", "test:css-order": "node testCSSOrder.mjs",
"test:swizzle:eject:js": "cross-env SWIZZLE_ACTION='eject' SWIZZLE_TYPESCRIPT='false' node _dogfooding/testSwizzleThemeClassic.mjs",
"test:swizzle:eject:ts": "cross-env SWIZZLE_ACTION='eject' SWIZZLE_TYPESCRIPT='true' node _dogfooding/testSwizzleThemeClassic.mjs",
"test:swizzle:wrap:js": "cross-env SWIZZLE_ACTION='wrap' SWIZZLE_TYPESCRIPT='false' node _dogfooding/testSwizzleThemeClassic.mjs",
"test:swizzle:wrap:ts": "cross-env SWIZZLE_ACTION='wrap' SWIZZLE_TYPESCRIPT='true' node _dogfooding/testSwizzleThemeClassic.mjs",
"write-translations": "docusaurus write-translations", "write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids", "write-heading-ids": "docusaurus write-heading-ids",
"start:baseUrl": "cross-env BASE_URL='/build/' yarn start", "start:baseUrl": "cross-env BASE_URL='/build/' yarn start",
@ -38,6 +42,8 @@
"@docusaurus/plugin-pwa": "2.0.0-beta.15", "@docusaurus/plugin-pwa": "2.0.0-beta.15",
"@docusaurus/preset-classic": "2.0.0-beta.15", "@docusaurus/preset-classic": "2.0.0-beta.15",
"@docusaurus/remark-plugin-npm2yarn": "2.0.0-beta.15", "@docusaurus/remark-plugin-npm2yarn": "2.0.0-beta.15",
"@docusaurus/logger": "2.0.0-beta.15",
"@docusaurus/theme-classic": "2.0.0-beta.15",
"@docusaurus/theme-common": "2.0.0-beta.15", "@docusaurus/theme-common": "2.0.0-beta.15",
"@docusaurus/theme-live-codeblock": "2.0.0-beta.15", "@docusaurus/theme-live-codeblock": "2.0.0-beta.15",
"@docusaurus/utils": "2.0.0-beta.15", "@docusaurus/utils": "2.0.0-beta.15",
@ -47,6 +53,7 @@
"clsx": "^1.1.1", "clsx": "^1.1.1",
"color": "^4.2.1", "color": "^4.2.1",
"esbuild-loader": "2.18.0", "esbuild-loader": "2.18.0",
"fs-extra": "^10.0.0",
"netlify-plugin-cache": "^1.0.3", "netlify-plugin-cache": "^1.0.3",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^17.0.2", "react": "^17.0.2",
@ -74,7 +81,6 @@
"devDependencies": { "devDependencies": {
"@tsconfig/docusaurus": "^1.0.4", "@tsconfig/docusaurus": "^1.0.4",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3"
"fs-extra": "^10.0.1"
} }
} }

View file

@ -87,6 +87,7 @@ const sidebars = {
], ],
}, },
'styling-layout', 'styling-layout',
'swizzling',
'static-assets', 'static-assets',
'search', 'search',
'browser-support', 'browser-support',
@ -126,8 +127,8 @@ const sidebars = {
'advanced/architecture', 'advanced/architecture',
'advanced/plugins', 'advanced/plugins',
'advanced/routing', 'advanced/routing',
'advanced/swizzling',
'advanced/ssg', 'advanced/ssg',
'advanced/client',
], ],
}, },
{ {

View file

@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
/* eslint-disable import/no-extraneous-dependencies */
// @ts-check // @ts-check

View file

@ -7,7 +7,6 @@
import path from 'path'; import path from 'path';
import {fileURLToPath} from 'url'; import {fileURLToPath} from 'url';
// eslint-disable-next-line import/no-extraneous-dependencies
import fs from 'fs-extra'; import fs from 'fs-extra';
/* /*

115
yarn.lock
View file

@ -5541,6 +5541,11 @@ better-opn@^3.0.0:
dependencies: dependencies:
open "^8.0.4" open "^8.0.4"
big-integer@^1.6.17:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
big.js@^5.2.2: big.js@^5.2.2:
version "5.2.2" version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -5551,6 +5556,14 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
binary@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
dependencies:
buffers "~0.1.1"
chainsaw "~0.1.0"
bindings@^1.4.0: bindings@^1.4.0:
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
@ -5580,6 +5593,11 @@ bluebird@^3.7.1:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bluebird@~3.4.1:
version "3.4.7"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=
blueimp-md5@^2.10.0: blueimp-md5@^2.10.0:
version "2.19.0" version "2.19.0"
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0" resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
@ -5774,6 +5792,11 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer-indexof-polyfill@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c"
integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==
buffer-indexof@^1.0.0: buffer-indexof@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c"
@ -5787,6 +5810,11 @@ buffer@^5.2.0, buffer@^5.2.1, buffer@^5.5.0:
base64-js "^1.3.1" base64-js "^1.3.1"
ieee754 "^1.1.13" ieee754 "^1.1.13"
buffers@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s=
builtin-modules@^3.0.0, builtin-modules@^3.1.0: builtin-modules@^3.0.0, builtin-modules@^3.1.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
@ -5988,6 +6016,13 @@ ccount@^1.0.0, ccount@^1.0.3:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043"
integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==
chainsaw@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=
dependencies:
traverse ">=0.3.0 <0.4"
chalk@^0.5.1: chalk@^0.5.1:
version "0.5.1" version "0.5.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174"
@ -6262,6 +6297,15 @@ cli-spinners@^2.5.0:
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d"
integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==
cli-table3@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.1.tgz#36ce9b7af4847f288d3cdd081fbd09bf7bd237b8"
integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==
dependencies:
string-width "^4.2.0"
optionalDependencies:
colors "1.4.0"
cli-truncate@^0.2.1: cli-truncate@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574"
@ -6509,7 +6553,7 @@ commander@^4.0.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
commander@^5.1.0: commander@^5.0.0, commander@^5.1.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
@ -7929,6 +7973,13 @@ download@^8.0.0:
p-event "^2.1.0" p-event "^2.1.0"
pify "^4.0.1" pify "^4.0.1"
duplexer2@~0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
dependencies:
readable-stream "^2.0.2"
duplexer3@^0.1.4: duplexer3@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
@ -8880,6 +8931,13 @@ fast-equals@^3.0.0:
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-3.0.0.tgz#efbe679d4c0d74040f61d4dda3e6bcb3bdccab82" resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-3.0.0.tgz#efbe679d4c0d74040f61d4dda3e6bcb3bdccab82"
integrity sha512-Af7nSOpf7617idrFg0MJY6x7yVDPoO80aSwtKTC0afT8B/SsmvTpA+2a+uPLmhVF5IHmY5NPuBAA3dJrp55rJA== integrity sha512-Af7nSOpf7617idrFg0MJY6x7yVDPoO80aSwtKTC0afT8B/SsmvTpA+2a+uPLmhVF5IHmY5NPuBAA3dJrp55rJA==
fast-folder-size@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/fast-folder-size/-/fast-folder-size-1.6.1.tgz#1dc1674842854032cf07a387ba77c66546c547eb"
integrity sha512-F3tRpfkAzb7TT2JNKaJUglyuRjRa+jelQD94s9OSqkfEeytLmupCqQiD+H2KoIXGtp4pB5m4zNmv5m2Ktcr+LA==
dependencies:
unzipper "^0.10.11"
fast-glob@^2.2.6: fast-glob@^2.2.6:
version "2.2.7" version "2.2.7"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
@ -9447,6 +9505,16 @@ fsevents@^2.3.2, fsevents@~2.3.2:
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
fstream@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
dependencies:
graceful-fs "^4.1.2"
inherits "~2.0.0"
mkdirp ">=0.5 0"
rimraf "2"
function-bind@^1.1.1: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@ -10655,7 +10723,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -12320,6 +12388,11 @@ lint-staged@^12.3.4:
supports-color "^9.2.1" supports-color "^9.2.1"
yaml "^1.10.2" yaml "^1.10.2"
listenercount@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=
listr-silent-renderer@^1.1.1: listr-silent-renderer@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e"
@ -13332,7 +13405,7 @@ mkdirp-infer-owner@^2.0.0:
infer-owner "^1.0.4" infer-owner "^1.0.4"
mkdirp "^1.0.3" mkdirp "^1.0.3"
mkdirp@^0.5.1, mkdirp@^0.5.5: "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.5:
version "0.5.5" version "0.5.5"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@ -15436,7 +15509,7 @@ prettier@^2.5.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
pretty-bytes@^5.3.0: pretty-bytes@^5.3.0, pretty-bytes@^5.6.0:
version "5.6.0" version "5.6.0"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
@ -16705,7 +16778,7 @@ rfdc@^1.3.0:
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
rimraf@^2.6.1, rimraf@^2.6.3: rimraf@2, rimraf@^2.6.1, rimraf@^2.6.3:
version "2.7.1" version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@ -17054,7 +17127,7 @@ set-value@^2.0.0, set-value@^2.0.1:
is-plain-object "^2.0.3" is-plain-object "^2.0.3"
split-string "^3.0.1" split-string "^3.0.1"
setimmediate@^1.0.5: setimmediate@^1.0.5, setimmediate@~1.0.4:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
@ -18485,11 +18558,25 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
"traverse@>=0.3.0 <0.4":
version "0.3.9"
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=
tree-kill@^1.2.2: tree-kill@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
tree-node-cli@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/tree-node-cli/-/tree-node-cli-1.5.2.tgz#c684fb9e7c2b9b29aa023eebaa9a095b6f93bf93"
integrity sha512-lBUNLk3NpRDkdsneWxa6mj5zfV/RZ5TWUniGuGprgmhijatHPcSMxyCs7bKpAqCLfPLZq7moQYLIiuVaWG/FOQ==
dependencies:
commander "^5.0.0"
fast-folder-size "^1.6.1"
pretty-bytes "^5.6.0"
trim-newlines@^3.0.0: trim-newlines@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
@ -19010,6 +19097,22 @@ untildify@^3.0.3:
resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9" resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA== integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
unzipper@^0.10.11:
version "0.10.11"
resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e"
integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==
dependencies:
big-integer "^1.6.17"
binary "~0.3.0"
bluebird "~3.4.1"
buffer-indexof-polyfill "~1.0.0"
duplexer2 "~0.1.4"
fstream "^1.0.12"
graceful-fs "^4.2.2"
listenercount "~1.0.1"
readable-stream "~2.3.6"
setimmediate "~1.0.4"
upath@^1.2.0: upath@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"