diff --git a/package.json b/package.json index 0e9d63abf99c..92d57c5705d0 100644 --- a/package.json +++ b/package.json @@ -129,5 +129,6 @@ "stylelint-config-standard": "^29.0.0", "typescript": "~5.8.2" }, + "resolutions": {}, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/create-docusaurus/bin/index.js b/packages/create-docusaurus/bin/index.js index f6b66f531d26..919043d10afc 100755 --- a/packages/create-docusaurus/bin/index.js +++ b/packages/create-docusaurus/bin/index.js @@ -8,8 +8,9 @@ // @ts-check -import path from 'path'; -import {createRequire} from 'module'; +import path from 'node:path'; +import {inspect} from 'node:util'; +import {createRequire} from 'node:module'; import {logger} from '@docusaurus/logger'; import semver from 'semver'; import {program} from 'commander'; @@ -61,7 +62,7 @@ if (!process.argv.slice(1).length) { program.outputHelp(); } -process.on('unhandledRejection', (err) => { - logger.error(err); +process.on('unhandledRejection', (error) => { + logger.error(inspect(error)); process.exit(1); }); diff --git a/packages/create-docusaurus/package.json b/packages/create-docusaurus/package.json index 179bd121f4a0..75a1f41e9bcb 100755 --- a/packages/create-docusaurus/package.json +++ b/packages/create-docusaurus/package.json @@ -23,10 +23,8 @@ "license": "MIT", "dependencies": { "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", "commander": "^5.1.0", - "execa": "^5.1.1", - "fs-extra": "^11.1.1", + "cross-spawn": "^7.0.0", "prompts": "^2.4.2", "semver": "^7.5.4", "supports-color": "^9.4.0", diff --git a/packages/create-docusaurus/src/index.ts b/packages/create-docusaurus/src/index.ts index 76b357b708d5..582262146132 100755 --- a/packages/create-docusaurus/src/index.ts +++ b/packages/create-docusaurus/src/index.ts @@ -5,18 +5,20 @@ * LICENSE file in the root directory of this source tree. */ -import fs from 'fs-extra'; -import {fileURLToPath} from 'url'; -import path from 'path'; +import * as fs from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +// KEEP DEPENDENCY SMALL HERE! +// create-docusaurus CLI should be as lightweight as possible + +// TODO try to remove these third-party dependencies if possible import {logger} from '@docusaurus/logger'; -import execa from 'execa'; import prompts, {type Choice} from 'prompts'; import supportsColor from 'supports-color'; -// TODO remove dependency on large @docusaurus/utils -// would be better to have a new smaller @docusaurus/utils-cli package -import {askPreferredLanguage} from '@docusaurus/utils'; -import {siteNameToPackageName} from './utils.js'; +import {runCommand, siteNameToPackageName} from './utils.js'; +import {askPreferredLanguage} from './prompts.js'; type LanguagesOptions = { javascript?: boolean; @@ -54,12 +56,18 @@ type PackageManager = keyof typeof lockfileNames; const packageManagers = Object.keys(lockfileNames) as PackageManager[]; +function pathExists(filePath: string): Promise { + return fs + .access(filePath, fs.constants.F_OK) + .then(() => true) + .catch(() => false); +} async function findPackageManagerFromLockFile( rootDir: string, ): Promise { for (const packageManager of packageManagers) { const lockFilePath = path.join(rootDir, lockfileNames[packageManager]); - if (await fs.pathExists(lockFilePath)) { + if (await pathExists(lockFilePath)) { return packageManager; } } @@ -73,9 +81,9 @@ function findPackageManagerFromUserAgent(): PackageManager | undefined { } async function askForPackageManagerChoice(): Promise { - const hasYarn = (await execa.command('yarn --version')).exitCode === 0; - const hasPnpm = (await execa.command('pnpm --version')).exitCode === 0; - const hasBun = (await execa.command('bun --version')).exitCode === 0; + const hasYarn = (await runCommand('yarn --version')) === 0; + const hasPnpm = (await runCommand('pnpm --version')) === 0; + const hasBun = (await runCommand('bun --version')) === 0; if (!hasYarn && !hasPnpm && !hasBun) { return 'npm'; @@ -156,7 +164,7 @@ async function readTemplates(): Promise { return { name, path: path.join(templatesDir, name), - tsVariantPath: (await fs.pathExists(tsVariantPath)) + tsVariantPath: (await pathExists(tsVariantPath)) ? tsVariantPath : undefined, }; @@ -180,12 +188,15 @@ async function copyTemplate( dest: string, language: 'javascript' | 'typescript', ): Promise { - await fs.copy(path.join(templatesDir, 'shared'), dest); + await fs.cp(path.join(templatesDir, 'shared'), dest, { + recursive: true, + }); const sourcePath = language === 'typescript' ? template.tsVariantPath! : template.path; - await fs.copy(sourcePath, dest, { + await fs.cp(sourcePath, dest, { + recursive: true, // Symlinks don't exist in published npm packages anymore, so this is only // to prevent errors during local testing filter: async (filePath) => !(await fs.lstat(filePath)).isSymbolicLink(), @@ -284,7 +295,7 @@ async function getSiteName( if (siteName === '.' && (await fs.readdir(dest)).length > 0) { return logger.interpolate`Directory not empty at path=${dest}!`; } - if (siteName !== '.' && (await fs.pathExists(dest))) { + if (siteName !== '.' && (await pathExists(dest))) { return logger.interpolate`Directory already exists at path=${dest}!`; } return true; @@ -392,7 +403,7 @@ async function getUserProvidedSource({ strategy: cliOptions.gitStrategy ?? 'deep', }; } - if (await fs.pathExists(path.resolve(reqTemplate))) { + if (await pathExists(path.resolve(reqTemplate))) { return { type: 'local', path: path.resolve(reqTemplate), @@ -472,7 +483,7 @@ async function askLocalSource(): Promise { validate: async (dir?: string) => { if (dir) { const fullDir = path.resolve(dir); - if (await fs.pathExists(fullDir)) { + if (await pathExists(fullDir)) { return true; } return logger.red( @@ -520,10 +531,13 @@ async function getSource( } async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) { - const pkg = (await fs.readJSON(pkgPath)) as {[key: string]: unknown}; + const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as { + [key: string]: unknown; + }; const newPkg = Object.assign(pkg, obj); - await fs.outputFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`); + await fs.mkdir(path.dirname(pkgPath), {recursive: true}); + await fs.writeFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`); } export default async function init( @@ -544,26 +558,33 @@ export default async function init( if (source.type === 'git') { const gitCommand = await getGitCommand(source.strategy); - if ((await execa(gitCommand, [source.url, dest])).exitCode !== 0) { + if ((await runCommand(gitCommand, [source.url, dest])) !== 0) { logger.error`Cloning Git template failed!`; process.exit(1); } if (source.strategy === 'copy') { - await fs.remove(path.join(dest, '.git')); + await fs.rm(path.join(dest, '.git'), { + force: true, + recursive: true, + }); } } else if (source.type === 'template') { try { await copyTemplate(source.template, dest, source.language); } catch (err) { - logger.error`Copying Docusaurus template name=${source.template.name} failed!`; - throw err; + throw new Error( + logger.interpolate`Copying Docusaurus template name=${source.template.name} failed!`, + {cause: err}, + ); } } else { try { - await fs.copy(source.path, dest); + await fs.cp(source.path, dest, {recursive: true}); } catch (err) { - logger.error`Copying local template path=${source.path} failed!`; - throw err; + throw new Error( + logger.interpolate`Copying local template path=${source.path} failed!`, + {cause: err}, + ); } } @@ -575,19 +596,21 @@ export default async function init( private: true, }); } catch (err) { - logger.error('Failed to update package.json.'); - throw err; + throw new Error('Failed to update package.json.', {cause: err}); } // We need to rename the gitignore file to .gitignore if ( - !(await fs.pathExists(path.join(dest, '.gitignore'))) && - (await fs.pathExists(path.join(dest, 'gitignore'))) + !(await pathExists(path.join(dest, '.gitignore'))) && + (await pathExists(path.join(dest, 'gitignore'))) ) { - await fs.move(path.join(dest, 'gitignore'), path.join(dest, '.gitignore')); + await fs.rename( + path.join(dest, 'gitignore'), + path.join(dest, '.gitignore'), + ); } - if (await fs.pathExists(path.join(dest, 'gitignore'))) { - await fs.remove(path.join(dest, 'gitignore')); + if (await pathExists(path.join(dest, 'gitignore'))) { + await fs.rm(path.join(dest, 'gitignore')); } // Display the most elegant way to cd. @@ -599,22 +622,21 @@ export default async function init( // ... if ( - ( - await execa.command( - pkgManager === 'yarn' - ? 'yarn' - : pkgManager === 'bun' - ? 'bun install' - : `${pkgManager} install --color always`, - { - env: { - ...process.env, - // Force coloring the output - ...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}), - }, + (await runCommand( + pkgManager === 'yarn' + ? 'yarn' + : pkgManager === 'bun' + ? 'bun install' + : `${pkgManager} install --color always`, + [], + { + env: { + ...process.env, + // Force coloring the output + ...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}), }, - ) - ).exitCode !== 0 + }, + )) !== 0 ) { logger.error('Dependency installation failed.'); logger.info`The site directory has already been created, and you can retry by typing: diff --git a/packages/create-docusaurus/src/prompts.ts b/packages/create-docusaurus/src/prompts.ts new file mode 100644 index 000000000000..fcbf00f13d2b --- /dev/null +++ b/packages/create-docusaurus/src/prompts.ts @@ -0,0 +1,27 @@ +/** + * 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 prompts from 'prompts'; +import {logger} from '@docusaurus/logger'; + +export async function askPreferredLanguage(): Promise< + 'javascript' | 'typescript' +> { + const {language} = await prompts({ + type: 'select', + name: 'language', + message: 'Which language do you want to use?', + choices: [ + {title: logger.bold('JavaScript'), value: 'javascript'}, + {title: logger.bold('TypeScript'), value: 'typescript'}, + ], + }); + if (!language) { + process.exit(0); + } + return language; +} diff --git a/packages/create-docusaurus/src/types.ts b/packages/create-docusaurus/src/types.ts new file mode 100644 index 000000000000..2a7d45e07784 --- /dev/null +++ b/packages/create-docusaurus/src/types.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; diff --git a/packages/create-docusaurus/src/utils.ts b/packages/create-docusaurus/src/utils.ts index 29bf0485b1e0..a98402cf163b 100644 --- a/packages/create-docusaurus/src/utils.ts +++ b/packages/create-docusaurus/src/utils.ts @@ -5,6 +5,47 @@ * LICENSE file in the root directory of this source tree. */ +// @ts-expect-error: no types, but same as spawn() +import CrossSpawn from 'cross-spawn'; +import type {spawn, SpawnOptions} from 'node:child_process'; + +// We use cross-spawn instead of spawn because of Windows compatibility issues. +// For example, "yarn" doesn't work on Windows, it requires "yarn.cmd" +// Tools like execa() use cross-spawn under the hood, and "resolve" the command +const crossSpawn: typeof spawn = CrossSpawn; + +/** + * Run a command, similar to execa(cmd,args) but simpler + * @param command + * @param args + * @param options + * @returns the command exit code + */ +export async function runCommand( + command: string, + args: string[] = [], + options: SpawnOptions = {}, +): Promise { + // This does something similar to execa.command() + // we split a string command (with optional args) into command+args + // this way it's compatible with spawn() + const [realCommand, ...baseArgs] = command.split(' '); + const allArgs = [...baseArgs, ...args]; + if (!realCommand) { + throw new Error(`Invalid command: ${command}`); + } + + return new Promise((resolve, reject) => { + const p = crossSpawn(realCommand, allArgs, {stdio: 'ignore', ...options}); + p.on('error', reject); + p.on('close', (exitCode) => + exitCode !== null + ? resolve(exitCode) + : reject(new Error(`No exit code for command ${command}`)), + ); + }); +} + /** * We use a simple kebab-case-like conversion * It's not perfect, but good enough diff --git a/packages/docusaurus-utils/src/cliUtils.ts b/packages/docusaurus-utils/src/cliUtils.ts deleted file mode 100644 index 53eaac7a72b5..000000000000 --- a/packages/docusaurus-utils/src/cliUtils.ts +++ /dev/null @@ -1,65 +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. - */ - -import prompts, {type Choice} from 'prompts'; -import logger from '@docusaurus/logger'; - -type PreferredLanguage = 'javascript' | 'typescript'; - -type AskPreferredLanguageOptions = { - fallback: PreferredLanguage | undefined; - exit: boolean; -}; - -const DefaultOptions: AskPreferredLanguageOptions = { - fallback: undefined, - exit: false, -}; - -const ExitChoice: Choice = {title: logger.yellow('[Exit]'), value: '[Exit]'}; - -export async function askPreferredLanguage( - options: Partial = {}, -): Promise<'javascript' | 'typescript'> { - const {fallback, exit} = {...DefaultOptions, ...options}; - - const choices: Choice[] = [ - {title: logger.bold('JavaScript'), value: 'javascript'}, - {title: logger.bold('TypeScript'), value: 'typescript'}, - ]; - if (exit) { - choices.push(ExitChoice); - } - - const {language} = await prompts( - { - type: 'select', - name: 'language', - message: 'Which language do you want to use?', - choices, - }, - { - onCancel() { - exit && process.exit(0); - }, - }, - ); - - if (language === ExitChoice.value) { - process.exit(0); - } - - if (!language) { - if (fallback) { - logger.info`Falling back to language=${fallback}`; - return fallback; - } - process.exit(0); - } - - return language; -} diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index bd93e1d19917..64159bc9dd96 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -119,7 +119,6 @@ export { } from './dataFileUtils'; export {isDraft, isUnlisted} from './contentVisibilityUtils'; export {escapeRegexp} from './regExpUtils'; -export {askPreferredLanguage} from './cliUtils'; export {flattenRoutes} from './routeUtils'; export { diff --git a/packages/docusaurus/src/commands/swizzle/index.ts b/packages/docusaurus/src/commands/swizzle/index.ts index c4c2a3ae4a6c..3a519e3c2382 100644 --- a/packages/docusaurus/src/commands/swizzle/index.ts +++ b/packages/docusaurus/src/commands/swizzle/index.ts @@ -7,7 +7,6 @@ import fs from 'fs-extra'; import logger from '@docusaurus/logger'; -import {askPreferredLanguage} from '@docusaurus/utils'; import { getThemeName, getThemePath, @@ -19,7 +18,10 @@ import {helpTables, themeComponentsTable} from './tables'; import {normalizeOptions} from './common'; import {eject, getAction, wrap} from './actions'; import {getThemeSwizzleConfig} from './config'; -import {askSwizzleDangerousComponent} from './prompts'; +import { + askSwizzleDangerousComponent, + askSwizzlePreferredLanguage, +} from './prompts'; import {initSwizzleContext} from './context'; import type {SwizzleAction, SwizzleComponentConfig} from '@docusaurus/types'; import type {SwizzleCLIOptions, SwizzlePlugin} from './common'; @@ -54,7 +56,7 @@ async function getLanguageForThemeName({ // It's only useful to prompt the user for themes that support both JS/TS if (supportsTS) { - return askPreferredLanguage({exit: true}); + return askSwizzlePreferredLanguage(); } return 'javascript'; diff --git a/packages/docusaurus/src/commands/swizzle/prompts.ts b/packages/docusaurus/src/commands/swizzle/prompts.ts index 35d85e33875d..8a5d56a587fe 100644 --- a/packages/docusaurus/src/commands/swizzle/prompts.ts +++ b/packages/docusaurus/src/commands/swizzle/prompts.ts @@ -6,7 +6,7 @@ */ import logger from '@docusaurus/logger'; -import prompts from 'prompts'; +import prompts, {type Choice} from 'prompts'; import {actionStatusSuffix, PartiallySafeHint} from './common'; import type {ThemeComponents} from './components'; import type {SwizzleAction, SwizzleComponentConfig} from '@docusaurus/types'; @@ -125,3 +125,33 @@ export async function askSwizzleAction( return action; } + +export async function askSwizzlePreferredLanguage(): Promise< + 'javascript' | 'typescript' +> { + const choices: Choice[] = [ + {title: logger.bold('JavaScript'), value: 'javascript'}, + {title: logger.bold('TypeScript'), value: 'typescript'}, + {title: logger.yellow('[Exit]'), value: '[Exit]'}, + ]; + + const {language} = await prompts( + { + type: 'select', + name: 'language', + message: 'Which language do you want to use?', + choices, + }, + { + onCancel() { + process.exit(0); + }, + }, + ); + + if (typeof language === 'undefined' || language === '[Exit]') { + process.exit(0); + } + + return language; +}