From 34503e71b913bbb474ba4f7fc3c4b2f79e693890 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Sun, 8 May 2022 20:15:46 +0800 Subject: [PATCH] refactor(create): clean up logic when prompting for unspecified arguments --- packages/create-docusaurus/src/index.ts | 586 ++++++++++++++---------- 1 file changed, 355 insertions(+), 231 deletions(-) diff --git a/packages/create-docusaurus/src/index.ts b/packages/create-docusaurus/src/index.ts index e913de7ef3da..b35e653b0a81 100755 --- a/packages/create-docusaurus/src/index.ts +++ b/packages/create-docusaurus/src/index.ts @@ -7,37 +7,39 @@ import logger from '@docusaurus/logger'; import fs from 'fs-extra'; +import {fileURLToPath} from 'url'; import prompts, {type Choice} from 'prompts'; import path from 'path'; import shell from 'shelljs'; import _ from 'lodash'; import supportsColor from 'supports-color'; -import {fileURLToPath} from 'url'; -const RecommendedTemplate = 'classic'; -const TypeScriptTemplateSuffix = '-typescript'; +type CLIOptions = { + packageManager?: PackageManager; + skipInstall?: boolean; + typescript?: boolean; + gitStrategy?: GitStrategy; +}; // Only used in the rare, rare case of running globally installed create + // using --skip-install. We need a default name to show the tip text -const DefaultPackageManager = 'npm'; +const defaultPackageManager = 'npm'; -const SupportedPackageManagers = { +const lockfileNames = { npm: 'package-lock.json', yarn: 'yarn.lock', pnpm: 'pnpm-lock.yaml', }; -type SupportedPackageManager = keyof typeof SupportedPackageManagers; +type PackageManager = keyof typeof lockfileNames; -const PackageManagersList = Object.keys( - SupportedPackageManagers, -) as SupportedPackageManager[]; +const packageManagers = Object.keys(lockfileNames) as PackageManager[]; -async function findPackageManagerFromLockFile(): Promise< - SupportedPackageManager | undefined -> { - for (const packageManager of PackageManagersList) { - const lockFilePath = path.resolve(SupportedPackageManagers[packageManager]); +async function findPackageManagerFromLockFile( + rootDir: string, +): Promise { + for (const packageManager of packageManagers) { + const lockFilePath = path.join(rootDir, lockfileNames[packageManager]); if (await fs.pathExists(lockFilePath)) { return packageManager; } @@ -45,15 +47,13 @@ async function findPackageManagerFromLockFile(): Promise< return undefined; } -function findPackageManagerFromUserAgent(): - | SupportedPackageManager - | undefined { - return PackageManagersList.find((packageManager) => +function findPackageManagerFromUserAgent(): PackageManager | undefined { + return packageManagers.find((packageManager) => process.env.npm_config_user_agent?.startsWith(packageManager), ); } -async function askForPackageManagerChoice(): Promise { +async function askForPackageManagerChoice(): Promise { const hasYarn = shell.exec('yarn --version', {silent: true}).code === 0; const hasPnpm = shell.exec('pnpm --version', {silent: true}).code === 0; @@ -65,97 +65,98 @@ async function askForPackageManagerChoice(): Promise { .map((p) => ({title: p, value: p})); return ( - await prompts({ - type: 'select', - name: 'packageManager', - message: 'Select a package manager...', - choices, - }) + await prompts( + { + type: 'select', + name: 'packageManager', + message: 'Select a package manager...', + choices, + }, + { + onCancel() { + logger.info`Falling back to name=${defaultPackageManager}`; + }, + }, + ) ).packageManager; } async function getPackageManager( - packageManagerChoice: SupportedPackageManager | undefined, - skipInstall: boolean = false, -): Promise { - if ( - packageManagerChoice && - !PackageManagersList.includes(packageManagerChoice) - ) { + dest: string, + {packageManager, skipInstall}: CLIOptions, +): Promise { + if (packageManager && !packageManagers.includes(packageManager)) { throw new Error( - `Invalid package manager choice ${packageManagerChoice}. Must be one of ${PackageManagersList.join( + `Invalid package manager choice ${packageManager}. Must be one of ${packageManagers.join( ', ', )}`, ); } return ( - packageManagerChoice ?? - (await findPackageManagerFromLockFile()) ?? + // If dest already contains a lockfile (e.g. if using a local template), we + // always use that instead + (await findPackageManagerFromLockFile(dest)) ?? + packageManager ?? + (await findPackageManagerFromLockFile('.')) ?? findPackageManagerFromUserAgent() ?? // This only happens if the user has a global installation in PATH - (skipInstall ? DefaultPackageManager : askForPackageManagerChoice()) + (skipInstall ? defaultPackageManager : askForPackageManagerChoice()) ?? + defaultPackageManager ); } -function isValidGitRepoUrl(gitRepoUrl: string) { - return ['https://', 'git@'].some((item) => gitRepoUrl.startsWith(item)); -} +const recommendedTemplate = 'classic'; +const typeScriptTemplateSuffix = '-typescript'; +const templatesDir = fileURLToPath(new URL('../templates', import.meta.url)); -async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) { - const pkg = await fs.readJSON(pkgPath); - const newPkg = Object.assign(pkg, obj); - - await fs.outputFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`); -} +type Template = { + name: string; + path: string; + tsVariantPath: string | undefined; +}; -async function readTemplates(templatesDir: string) { - const templates = (await fs.readdir(templatesDir)).filter( - (d) => - !d.startsWith('.') && - !d.startsWith('README') && - !d.endsWith(TypeScriptTemplateSuffix) && - d !== 'shared', +async function readTemplates(): Promise { + const dirContents = await fs.readdir(templatesDir); + const templates = await Promise.all( + dirContents + .filter( + (d) => + !d.startsWith('.') && + !d.startsWith('README') && + !d.endsWith(typeScriptTemplateSuffix) && + d !== 'shared', + ) + .map(async (name) => { + const tsVariantPath = path.join( + templatesDir, + `${name}${typeScriptTemplateSuffix}`, + ); + return { + name, + path: path.join(templatesDir, name), + tsVariantPath: (await fs.pathExists(tsVariantPath)) + ? tsVariantPath + : undefined, + }; + }), ); // Classic should be first in list! - return _.sortBy(templates, (t) => t !== RecommendedTemplate); -} - -function createTemplateChoices(templates: string[]) { - function makeNameAndValueChoice(value: string): Choice { - const title = - value === RecommendedTemplate ? `${value} (recommended)` : value; - return {title, value}; - } - - return [ - ...templates.map((template) => makeNameAndValueChoice(template)), - makeNameAndValueChoice('Git repository'), - makeNameAndValueChoice('Local template'), - ]; -} - -function getTypeScriptBaseTemplate(template: string): string | undefined { - if (template.endsWith(TypeScriptTemplateSuffix)) { - return template.replace(TypeScriptTemplateSuffix, ''); - } - return undefined; + return _.sortBy(templates, (t) => t.name !== recommendedTemplate); } async function copyTemplate( - templatesDir: string, - template: string, + template: Template, dest: string, -) { + typescript: boolean, +): Promise { await fs.copy(path.join(templatesDir, 'shared'), dest); // TypeScript variants will copy duplicate resources like CSS & config from // base template - const tsBaseTemplate = getTypeScriptBaseTemplate(template); - if (tsBaseTemplate) { - const tsBaseTemplatePath = path.resolve(templatesDir, tsBaseTemplate); - await fs.copy(tsBaseTemplatePath, dest, { + if (typescript) { + await fs.copy(template.path, dest, { filter: async (filePath) => (await fs.stat(filePath)).isDirectory() || path.extname(filePath) === '.css' || @@ -163,28 +164,59 @@ async function copyTemplate( }); } - await fs.copy(path.resolve(templatesDir, template), dest, { + await fs.copy(typescript ? template.tsVariantPath! : template.path, dest, { // 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(), }); } +function createTemplateChoices(templates: Template[]): Choice[] { + function makeNameAndValueChoice(value: string | Template): Choice { + if (typeof value === 'string') { + return {title: value, value}; + } + const title = + value.name === recommendedTemplate + ? `${value.name} (recommended)` + : value.name; + return {title, value}; + } + + return [ + ...templates.map((template) => makeNameAndValueChoice(template)), + makeNameAndValueChoice('Git repository'), + makeNameAndValueChoice('Local template'), + ]; +} + +function isValidGitRepoUrl(gitRepoUrl: string): boolean { + return ['https://', 'git@'].some((item) => gitRepoUrl.startsWith(item)); +} + const gitStrategies = ['deep', 'shallow', 'copy', 'custom'] as const; +type GitStrategy = typeof gitStrategies[number]; -async function getGitCommand(gitStrategy: typeof gitStrategies[number]) { +async function getGitCommand(gitStrategy: GitStrategy): Promise { switch (gitStrategy) { case 'shallow': case 'copy': return 'git clone --recursive --depth 1'; case 'custom': { - const {command} = await prompts({ - type: 'text', - name: 'command', - message: - 'Write your own git clone command. The repository URL and destination directory will be supplied. E.g. "git clone --depth 10"', - }); - return command; + const {command} = await prompts( + { + type: 'text', + name: 'command', + message: + 'Write your own git clone command. The repository URL and destination directory will be supplied. E.g. "git clone --depth 10"', + }, + { + onCancel() { + logger.info`Falling back to code=${'git clone'}`; + }, + }, + ); + return command ?? 'git clone'; } case 'deep': default: @@ -192,178 +224,273 @@ async function getGitCommand(gitStrategy: typeof gitStrategies[number]) { } } -export default async function init( +async function getSiteName( + reqName: string | undefined, rootDir: string, - siteName?: string, - reqTemplate?: string, - cliOptions: Partial<{ - packageManager: SupportedPackageManager; - skipInstall: boolean; - typescript: boolean; - gitStrategy: typeof gitStrategies[number]; - }> = {}, -): Promise { - const templatesDir = fileURLToPath(new URL('../templates', import.meta.url)); - const templates = await readTemplates(templatesDir); - const hasTS = (templateName: string) => - fs.pathExists( - path.join(templatesDir, `${templateName}${TypeScriptTemplateSuffix}`), - ); - let name = siteName; - - // Prompt if siteName is not passed from CLI. - if (!name) { - const prompt = await prompts({ +): Promise { + async function validateSiteName(siteName: string) { + if (!siteName) { + return 'A website name is required.'; + } + const dest = path.resolve(rootDir, siteName); + if (await fs.pathExists(dest)) { + return logger.interpolate`Directory already exists at path=${dest}!`; + } + return true; + } + if (reqName) { + const res = validateSiteName(reqName); + if (typeof res === 'string') { + throw new Error(res); + } + return reqName; + } + const {siteName} = await prompts( + { type: 'text', - name: 'name', + name: 'siteName', message: 'What should we name this site?', initial: 'website', - }); - name = prompt.name; - } - - if (!name) { - logger.error('A website name is required.'); - process.exit(1); - } - - const dest = path.resolve(rootDir, name); - if (await fs.pathExists(dest)) { - logger.error`Directory already exists at path=${dest}!`; - process.exit(1); - } + validate: validateSiteName, + }, + { + onCancel() { + logger.error('A website name is required.'); + process.exit(1); + }, + }, + ); + return siteName; +} - let template = reqTemplate; - let useTS = cliOptions.typescript; - // Prompt if template is not provided from CLI. - if (!template) { - const templatePrompt = await prompts({ - type: 'select', - name: 'template', - message: 'Select a template below...', - choices: createTemplateChoices(templates), - }); - template = templatePrompt.template; - if (template && !useTS && (await hasTS(template))) { - const tsPrompt = await prompts({ - type: 'confirm', - name: 'useTS', - message: - 'This template is available in TypeScript. Do you want to use the TS variant?', - initial: false, - }); - useTS = tsPrompt.useTS; +type Source = + | { + type: 'template'; + template: Template; + typescript: boolean; } + | { + type: 'git'; + url: string; + strategy: GitStrategy; + } + | { + type: 'local'; + path: string; + }; + +async function getSource( + reqTemplate: string | undefined, + templates: Template[], + cliOptions: CLIOptions, +): Promise { + if (reqTemplate) { + if (isValidGitRepoUrl(reqTemplate)) { + if ( + cliOptions.gitStrategy && + !gitStrategies.includes(cliOptions.gitStrategy) + ) { + logger.error`Invalid git strategy: name=${ + cliOptions.gitStrategy + }. Value must be one of ${gitStrategies.join(', ')}.`; + process.exit(1); + } + return { + type: 'git', + url: reqTemplate, + strategy: cliOptions.gitStrategy ?? 'deep', + }; + } else if (await fs.pathExists(path.resolve(reqTemplate))) { + return { + type: 'local', + path: path.resolve(reqTemplate), + }; + } + const template = templates.find((t) => t.name === reqTemplate); + if (!template) { + logger.error('Invalid template.'); + process.exit(1); + } + if (cliOptions.typescript && !template.tsVariantPath) { + logger.error`Template name=${reqTemplate} doesn't provide the TypeScript variant.`; + process.exit(1); + } + return { + type: 'template', + template, + typescript: cliOptions.typescript ?? false, + }; } - - let gitStrategy = cliOptions.gitStrategy ?? 'deep'; - - // If user choose Git repository, we'll prompt for the url. + const template = cliOptions.gitStrategy + ? 'Git repository' + : ( + await prompts( + { + type: 'select', + name: 'template', + message: 'Select a template below...', + choices: createTemplateChoices(templates), + }, + { + onCancel() { + logger.error('A choice is required.'); + process.exit(1); + }, + }, + ) + ).template; if (template === 'Git repository') { - const repoPrompt = await prompts({ - type: 'text', - name: 'gitRepoUrl', - validate: (url?: string) => { - if (url && isValidGitRepoUrl(url)) { - return true; - } - return logger.red('Invalid repository URL'); - }, - message: logger.interpolate`Enter a repository URL from GitHub, Bitbucket, GitLab, or any other public repo. + const {gitRepoUrl} = await prompts( + { + type: 'text', + name: 'gitRepoUrl', + validate: (url?: string) => { + if (url && isValidGitRepoUrl(url)) { + return true; + } + return logger.red('Invalid repository URL'); + }, + message: logger.interpolate`Enter a repository URL from GitHub, Bitbucket, GitLab, or any other public repo. (e.g: url=${'https://github.com/ownerName/repoName.git'})`, - }); - ({gitStrategy} = await prompts({ - type: 'select', - name: 'gitStrategy', - message: 'How should we clone this repo?', - choices: [ - {title: 'Deep clone: preserve full history', value: 'deep'}, - {title: 'Shallow clone: clone with --depth=1', value: 'shallow'}, + }, + { + onCancel() { + logger.error('A git repo URL is required.'); + process.exit(1); + }, + }, + ); + let strategy = cliOptions.gitStrategy; + if (!strategy) { + ({strategy} = await prompts( { - title: 'Copy: do a shallow clone, but do not create a git repo', - value: 'copy', + type: 'select', + name: 'strategy', + message: 'How should we clone this repo?', + choices: [ + {title: 'Deep clone: preserve full history', value: 'deep'}, + {title: 'Shallow clone: clone with --depth=1', value: 'shallow'}, + { + title: 'Copy: do a shallow clone, but do not create a git repo', + value: 'copy', + }, + { + title: 'Custom: enter your custom git clone command', + value: 'custom', + }, + ], }, - {title: 'Custom: enter your custom git clone command', value: 'custom'}, - ], - })); - template = repoPrompt.gitRepoUrl; + { + onCancel() { + logger.info`Falling back to name=${'deep'}`; + }, + }, + )); + } + return { + type: 'git', + url: gitRepoUrl, + strategy: strategy ?? 'deep', + }; } else if (template === 'Local template') { - const dirPrompt = await prompts({ - type: 'text', - name: 'templateDir', - validate: async (dir?: string) => { - if (dir) { - const fullDir = path.resolve(dir); - if (await fs.pathExists(fullDir)) { - return true; + const {templateDir} = await prompts( + { + type: 'text', + name: 'templateDir', + validate: async (dir?: string) => { + if (dir) { + const fullDir = path.resolve(dir); + if (await fs.pathExists(fullDir)) { + return true; + } + return logger.red( + logger.interpolate`path=${fullDir} does not exist.`, + ); } - return logger.red( - logger.interpolate`path=${fullDir} does not exist.`, - ); - } - return logger.red('Please enter a valid path.'); + return logger.red('Please enter a valid path.'); + }, + message: + 'Enter a local folder path, relative to the current working directory.', + }, + { + onCancel() { + logger.error('A file path is required.'); + process.exit(1); + }, }, + ); + return { + type: 'local', + path: templateDir, + }; + } + let useTS = cliOptions.typescript; + if (!useTS && template.tsVariantPath) { + ({useTS} = await prompts({ + type: 'confirm', + name: 'useTS', message: - 'Enter a local folder path, relative to the current working directory.', - }); - template = dirPrompt.templateDir; + 'This template is available in TypeScript. Do you want to use the TS variant?', + initial: false, + })); } + return { + type: 'template', + template, + typescript: useTS ?? false, + }; +} - if (!template) { - logger.error('Template should not be empty'); - process.exit(1); - } +async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) { + const pkg = await fs.readJSON(pkgPath); + const newPkg = Object.assign(pkg, obj); + + await fs.outputFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`); +} + +export default async function init( + rootDir: string, + reqName?: string, + reqTemplate?: string, + cliOptions: CLIOptions = {}, +): Promise { + const templates = await readTemplates(); + const siteName = await getSiteName(reqName, rootDir); + const dest = path.resolve(rootDir, siteName); + const source = await getSource(reqTemplate, templates, cliOptions); logger.info('Creating new Docusaurus project...'); - if (isValidGitRepoUrl(template)) { - logger.info`Cloning Git template url=${template}...`; - if (!gitStrategies.includes(gitStrategy)) { - logger.error`Invalid git strategy: name=${gitStrategy}. Value must be one of ${gitStrategies.join( - ', ', - )}.`; + if (source.type === 'git') { + logger.info`Cloning Git template url=${source.url}...`; + const command = await getGitCommand(source.strategy); + if (shell.exec(`${command} ${source.url} ${dest}`).code !== 0) { + logger.error`Cloning Git template failed!`; process.exit(1); } - const command = await getGitCommand(gitStrategy); - if (shell.exec(`${command} ${template} ${dest}`).code !== 0) { - logger.error`Cloning Git template name=${template} failed!`; - process.exit(1); - } - if (gitStrategy === 'copy') { + if (source.strategy === 'copy') { await fs.remove(path.join(dest, '.git')); } - } else if (templates.includes(template)) { - // Docusaurus templates. - if (useTS) { - if (!(await hasTS(template))) { - logger.error`Template name=${template} doesn't provide the TypeScript variant.`; - process.exit(1); - } - template = `${template}${TypeScriptTemplateSuffix}`; - } + } else if (source.type === 'template') { try { - await copyTemplate(templatesDir, template, dest); + await copyTemplate(source.template, dest, source.typescript); } catch (err) { - logger.error`Copying Docusaurus template name=${template} failed!`; + logger.error`Copying Docusaurus template name=${source.template.name} failed!`; throw err; } - } else if (await fs.pathExists(path.resolve(template))) { - const templateDir = path.resolve(template); + } else { try { - await fs.copy(templateDir, dest); + await fs.copy(source.path, dest); } catch (err) { - logger.error`Copying local template path=${templateDir} failed!`; + logger.error`Copying local template path=${source.path} failed!`; throw err; } - } else { - logger.error('Invalid template.'); - process.exit(1); } // Update package.json info. try { await updatePkg(path.join(dest, 'package.json'), { - name: _.kebabCase(name), + name: _.kebabCase(siteName), version: '0.0.0', private: true, }); @@ -385,10 +512,7 @@ export default async function init( // Display the most elegant way to cd. const cdpath = path.relative('.', dest); - const pkgManager = await getPackageManager( - cliOptions.packageManager, - cliOptions.skipInstall, - ); + const pkgManager = await getPackageManager(dest, cliOptions); if (!cliOptions.skipInstall) { shell.cd(dest); logger.info`Installing dependencies with name=${pkgManager}...`; @@ -398,8 +522,8 @@ export default async function init( { env: { ...process.env, - // Force coloring the output, since the command is invoked, - // by shelljs which is not the interactive shell + // Force coloring the output, since the command is invoked by + // shelljs, which is not an interactive shell ...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}), }, },