diff --git a/admin/scripts/generateExamples.js b/admin/scripts/generateExamples.js index 1bb3646d63f7..56e0bb87f6f1 100644 --- a/admin/scripts/generateExamples.js +++ b/admin/scripts/generateExamples.js @@ -25,7 +25,8 @@ async function generateTemplateExample(template) { // Run the docusaurus script to create the template in the examples folder const command = template.endsWith('-typescript') ? template.replace('-typescript', ' -- --typescript') - : template; + : `${template} -- --javascript`; + shell.exec( // We use the published init script on purpose, because the local init is // too new and could generate upcoming/unavailable config options. diff --git a/admin/scripts/test-release.sh b/admin/scripts/test-release.sh index 1834834b8fa6..622c524c95fa 100755 --- a/admin/scripts/test-release.sh +++ b/admin/scripts/test-release.sh @@ -52,7 +52,7 @@ git diff --name-only -- '*.json' | sed 's, ,\\&,g' | xargs git checkout -- cd .. # Build skeleton website with new version -npm_config_registry="$CUSTOM_REGISTRY_URL" npx create-docusaurus@"$NEW_VERSION" test-website classic $EXTRA_OPTS +npm_config_registry="$CUSTOM_REGISTRY_URL" npx create-docusaurus@"$NEW_VERSION" test-website classic --javascript $EXTRA_OPTS # Stop Docker container if [[ -z "${KEEP_CONTAINER:-true}" ]] && ( $(docker container inspect "$CONTAINER_NAME" > /dev/null 2>&1) ); then diff --git a/packages/create-docusaurus/README.md b/packages/create-docusaurus/README.md index 2533546271a8..2c62a47257b9 100644 --- a/packages/create-docusaurus/README.md +++ b/packages/create-docusaurus/README.md @@ -25,7 +25,7 @@ For Docusaurus maintainers, templates can be tested with: ```bash cd `git rev-parse --show-toplevel` # Back to repo root rm -rf test-website -yarn create-docusaurus test-website classic +yarn create-docusaurus test-website classic --javascript cd test-website yarn start ``` @@ -37,7 +37,7 @@ Use the following to test the templates against local packages: ```bash cd `git rev-parse --show-toplevel` # Back to repo root rm -rf test-website-in-workspace -yarn create-docusaurus test-website-in-workspace classic +yarn create-docusaurus test-website-in-workspace classic --javascript cd test-website-in-workspace yarn build yarn start diff --git a/packages/create-docusaurus/bin/index.js b/packages/create-docusaurus/bin/index.js index 6228ba8a0524..84d0c9e4f1d9 100755 --- a/packages/create-docusaurus/bin/index.js +++ b/packages/create-docusaurus/bin/index.js @@ -38,6 +38,7 @@ program 'Do not run package manager immediately after scaffolding', ) .option('-t, --typescript', 'Use the TypeScript template variant') + .option('-j, --javascript', 'Use the JavaScript template variant') .option( '-g, --git-strategy ', `Only used if the template is a git repository. diff --git a/packages/create-docusaurus/src/index.ts b/packages/create-docusaurus/src/index.ts index f216e3651c47..769fb4885213 100755 --- a/packages/create-docusaurus/src/index.ts +++ b/packages/create-docusaurus/src/index.ts @@ -13,15 +13,29 @@ import logger from '@docusaurus/logger'; import shell from 'shelljs'; import prompts, {type Choice} from 'prompts'; import supportsColor from 'supports-color'; -import {escapeShellArg} from '@docusaurus/utils'; +import {escapeShellArg, askPreferredLanguage} from '@docusaurus/utils'; -type CLIOptions = { +type LanguagesOptions = { + javascript?: boolean; + typescript?: boolean; +}; + +type CLIOptions = LanguagesOptions & { packageManager?: PackageManager; skipInstall?: boolean; - typescript?: boolean; gitStrategy?: GitStrategy; }; +async function getLanguage(options: LanguagesOptions) { + if (options.typescript) { + return 'typescript'; + } + if (options.javascript) { + return 'javascript'; + } + return askPreferredLanguage(); +} + // 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'; @@ -153,11 +167,14 @@ async function readTemplates(): Promise { async function copyTemplate( template: Template, dest: string, - typescript: boolean, + language: 'javascript' | 'typescript', ): Promise { await fs.copy(path.join(templatesDir, 'shared'), dest); - await fs.copy(typescript ? template.tsVariantPath! : template.path, dest, { + const sourcePath = + language === 'typescript' ? template.tsVariantPath! : template.path; + + await fs.copy(sourcePath, 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(), @@ -183,6 +200,33 @@ function createTemplateChoices(templates: Template[]): Choice[] { ]; } +async function askTemplateChoice({ + templates, + cliOptions, +}: { + templates: Template[]; + cliOptions: CLIOptions; +}) { + return 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); + }, + }, + )) as {template: Template | 'Git repository' | 'Local template'} + ).template; +} + function isValidGitRepoUrl(gitRepoUrl: string): boolean { return ['https://', 'git@'].some((item) => gitRepoUrl.startsWith(item)); } @@ -260,7 +304,7 @@ type Source = | { type: 'template'; template: Template; - typescript: boolean; + language: 'javascript' | 'typescript'; } | { type: 'git'; @@ -272,166 +316,193 @@ type Source = 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.`; +async function createTemplateSource({ + template, + cliOptions, +}: { + template: Template; + cliOptions: CLIOptions; +}): Promise { + const language = await getLanguage(cliOptions); + if (language === 'typescript' && !template.tsVariantPath) { + logger.error`Template name=${template.name} doesn't provide a TypeScript variant.`; + process.exit(1); + } + return { + type: 'template', + template, + language, + }; +} + +async function getTemplateSource({ + templateName, + templates, + cliOptions, +}: { + templateName: string; + templates: Template[]; + cliOptions: CLIOptions; +}): Promise { + const template = templates.find((t) => t.name === templateName); + if (!template) { + logger.error('Invalid template.'); + process.exit(1); + } + return createTemplateSource({template, cliOptions}); +} + +// Get the template source explicitly requested by the user provided cli option +async function getUserProvidedSource({ + reqTemplate, + templates, + cliOptions, +}: { + reqTemplate: string; + templates: Template[]; + cliOptions: CLIOptions; +}): Promise { + 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: 'template', - template, - typescript: cliOptions.typescript ?? false, + type: 'git', + url: reqTemplate, + strategy: cliOptions.gitStrategy ?? 'deep', }; } - const template = cliOptions.gitStrategy - ? 'Git repository' - : ( - (await prompts( + if (await fs.pathExists(path.resolve(reqTemplate))) { + return { + type: 'local', + path: path.resolve(reqTemplate), + }; + } + return getTemplateSource({ + templateName: reqTemplate, + templates, + cliOptions, + }); +} + +async function askGitRepositorySource({ + cliOptions, +}: { + cliOptions: CLIOptions; +}): Promise { + 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'})`, + }, + { + onCancel() { + logger.error('A git repo URL is required.'); + process.exit(1); + }, + }, + )) as {gitRepoUrl: string}; + let strategy = cliOptions.gitStrategy; + if (!strategy) { + ({strategy} = (await prompts( + { + 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'}, { - type: 'select', - name: 'template', - message: 'Select a template below...', - choices: createTemplateChoices(templates), + title: 'Copy: do a shallow clone, but do not create a git repo', + value: 'copy', }, { - onCancel() { - logger.error('A choice is required.'); - process.exit(1); - }, + title: 'Custom: enter your custom git clone command', + value: 'custom', }, - )) as {template: Template | 'Git repository' | 'Local template'} - ).template; - if (template === 'Git repository') { - 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'})`, + ], }, { onCancel() { - logger.error('A git repo URL is required.'); - process.exit(1); + logger.info`Falling back to name=${'deep'}`; }, }, - )) as {gitRepoUrl: string}; - let strategy = cliOptions.gitStrategy; - if (!strategy) { - ({strategy} = (await prompts( - { - 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', - }, - ], - }, - { - onCancel() { - logger.info`Falling back to name=${'deep'}`; - }, - }, - )) as {strategy?: GitStrategy}); - } - return { - type: 'git', - url: gitRepoUrl, - strategy: strategy ?? 'deep', - }; - } else if (template === 'Local template') { - 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.`, - ); + )) as {strategy?: GitStrategy}); + } + return { + type: 'git', + url: gitRepoUrl, + strategy: strategy ?? 'deep', + }; +} + +async function askLocalSource(): Promise { + 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('Please enter a valid path.'); - }, - message: - 'Enter a local folder path, relative to the current working directory.', + return logger.red( + logger.interpolate`path=${fullDir} does not exist.`, + ); + } + return logger.red('Please enter a valid path.'); }, - { - onCancel() { - logger.error('A file path is required.'); - process.exit(1); - }, + message: + 'Enter a local folder path, relative to the current working directory.', + }, + { + onCancel() { + logger.error('A file path is required.'); + process.exit(1); }, - )) as {templateDir: string}; - return { - type: 'local', - path: templateDir, - }; + }, + )) as {templateDir: string}; + return { + type: 'local', + path: templateDir, + }; +} + +async function getSource( + reqTemplate: string | undefined, + templates: Template[], + cliOptions: CLIOptions, +): Promise { + if (reqTemplate) { + return getUserProvidedSource({reqTemplate, templates, cliOptions}); } - let useTS = cliOptions.typescript; - if (!useTS && template.tsVariantPath) { - ({useTS} = (await prompts({ - type: 'confirm', - name: 'useTS', - message: - 'This template is available in TypeScript. Do you want to use the TS variant?', - initial: false, - })) as {useTS?: boolean}); + + const template = await askTemplateChoice({templates, cliOptions}); + if (template === 'Git repository') { + return askGitRepositorySource({cliOptions}); } - return { - type: 'template', + if (template === 'Local template') { + return askLocalSource(); + } + return createTemplateSource({ template, - typescript: useTS ?? false, - }; + cliOptions, + }); } async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) { @@ -452,6 +523,7 @@ export default async function init( getSiteName(reqName, rootDir), ]); const dest = path.resolve(rootDir, siteName); + const source = await getSource(reqTemplate, templates, cliOptions); logger.info('Creating new Docusaurus project...'); @@ -470,7 +542,7 @@ export default async function init( } } else if (source.type === 'template') { try { - await copyTemplate(source.template, dest, source.typescript); + await copyTemplate(source.template, dest, source.language); } catch (err) { logger.error`Copying Docusaurus template name=${source.template.name} failed!`; throw err; diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index 47194c20ec79..b3fa5dee583d 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -30,6 +30,7 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "micromatch": "^4.0.5", + "prompts": "^2.4.2", "resolve-pathname": "^3.0.0", "shelljs": "^0.8.5", "tslib": "^2.6.0", diff --git a/packages/docusaurus-utils/src/cliUtils.ts b/packages/docusaurus-utils/src/cliUtils.ts new file mode 100644 index 000000000000..53eaac7a72b5 --- /dev/null +++ b/packages/docusaurus-utils/src/cliUtils.ts @@ -0,0 +1,65 @@ +/** + * 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 6db01244d006..dc5fc1e1bf39 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -117,3 +117,4 @@ export { } from './dataFileUtils'; export {isDraft, isUnlisted} from './contentVisibilityUtils'; export {escapeRegexp} from './regExpUtils'; +export {askPreferredLanguage} from './cliUtils';