diff --git a/packages/create-next-app/create-app.ts b/packages/create-next-app/create-app.ts index 2f6c558f63f5..9ef35850afe5 100644 --- a/packages/create-next-app/create-app.ts +++ b/packages/create-next-app/create-app.ts @@ -5,7 +5,14 @@ import makeDir from 'make-dir' import os from 'os' import path from 'path' -import { downloadAndExtractExample, hasExample } from './helpers/examples' +import { + hasExample, + hasRepo, + getRepoInfo, + downloadAndExtractExample, + downloadAndExtractRepo, + RepoInfo, +} from './helpers/examples' import { tryGitInit } from './helpers/git' import { install } from './helpers/install' import { isFolderEmpty } from './helpers/is-folder-empty' @@ -16,20 +23,69 @@ export async function createApp({ appPath, useNpm, example, + examplePath, }: { appPath: string useNpm: boolean example?: string + examplePath?: string }) { + let repoInfo: RepoInfo | undefined + if (example) { - const found = await hasExample(example) - if (!found) { - console.error( - `Could not locate an example named ${chalk.red( - `"${example}"` - )}. Please check your spelling and try again.` - ) - process.exit(1) + let repoUrl: URL | undefined + + try { + repoUrl = new URL(example) + } catch (error) { + if (error.code !== 'ERR_INVALID_URL') { + console.error(error) + process.exit(1) + } + } + + if (repoUrl) { + if (repoUrl.origin !== 'https://github.com') { + console.error( + `Invalid URL: ${chalk.red( + `"${example}"` + )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` + ) + process.exit(1) + } + + repoInfo = getRepoInfo(repoUrl, examplePath) + + if (!repoInfo) { + console.error( + `Found invalid GitHub URL: ${chalk.red( + `"${example}"` + )}. Please fix the URL and try again.` + ) + process.exit(1) + } + + const found = await hasRepo(repoInfo) + + if (!found) { + console.error( + `Could not locate the repository for ${chalk.red( + `"${example}"` + )}. Please check that the repository exists and try again.` + ) + process.exit(1) + } + } else { + const found = await hasExample(example) + + if (!found) { + console.error( + `Could not locate an example named ${chalk.red( + `"${example}"` + )}. Please check your spelling and try again.` + ) + process.exit(1) + } } } @@ -53,13 +109,23 @@ export async function createApp({ process.chdir(root) if (example) { - console.log( - `Downloading files for example ${chalk.cyan( - example - )}. This might take a moment.` - ) - console.log() - await downloadAndExtractExample(root, example) + if (repoInfo) { + console.log( + `Downloading files from repo ${chalk.cyan( + example + )}. This might take a moment.` + ) + console.log() + await downloadAndExtractRepo(root, repoInfo) + } else { + console.log( + `Downloading files for example ${chalk.cyan( + example + )}. This might take a moment.` + ) + console.log() + await downloadAndExtractExample(root, example) + } // Copy our default `.gitignore` if the application did not provide one const ignorePath = path.join(root, '.gitignore') diff --git a/packages/create-next-app/helpers/examples.ts b/packages/create-next-app/helpers/examples.ts index 746f5cd3a860..2b0dad8d5c33 100644 --- a/packages/create-next-app/helpers/examples.ts +++ b/packages/create-next-app/helpers/examples.ts @@ -2,20 +2,69 @@ import got from 'got' import promisePipe from 'promisepipe' import tar from 'tar' -export async function hasExample(name: string): Promise { - const res = await got( +export type RepoInfo = { + username: string + name: string + branch: string + filePath: string +} + +export async function isUrlOk(url: string) { + const res = await got(url).catch(e => e) + return res.statusCode === 200 +} + +export function getRepoInfo( + url: URL, + examplePath?: string +): RepoInfo | undefined { + const [, username, name, t, _branch, ...file] = url.pathname.split('/') + const filePath = examplePath ? examplePath.replace(/^\//, '') : file.join('/') + // If examplePath is available, the branch name takes the entire path + const branch = examplePath + ? `${_branch}/${file.join('/')}`.replace(new RegExp(`/${filePath}|/$`), '') + : _branch + + if (username && name && branch && t === 'tree') { + return { username, name, branch, filePath } + } +} + +export function hasRepo({ username, name, branch, filePath }: RepoInfo) { + const contentsUrl = `https://api.github.com/repos/${username}/${name}/contents` + const packagePath = `${filePath ? `/${filePath}` : ''}/package.json` + + return isUrlOk(contentsUrl + packagePath + `?ref=${branch}`) +} + +export function hasExample(name: string): Promise { + return isUrlOk( `https://api.github.com/repos/zeit/next.js/contents/examples/${encodeURIComponent( name )}/package.json` - ).catch(e => e) - return res.statusCode === 200 + ) +} + +export function downloadAndExtractRepo( + root: string, + { username, name, branch, filePath }: RepoInfo +): Promise { + return promisePipe( + got.stream( + `https://codeload.github.com/${username}/${name}/tar.gz/${branch}` + ), + tar.extract( + { cwd: root, strip: filePath ? filePath.split('/').length + 1 : 1 }, + [`${name}-${branch}${filePath ? `/${filePath}` : ''}`] + ) + ) } -export async function downloadAndExtractExample( +export function downloadAndExtractExample( root: string, name: string ): Promise { - return await promisePipe( + return promisePipe( got.stream('https://codeload.github.com/zeit/next.js/tar.gz/canary'), tar.extract({ cwd: root, strip: 3 }, [`next.js-canary/examples/${name}`]) ) diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index 99377ebcd6fc..326a0698a3c6 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -21,8 +21,23 @@ const program = new Commander.Command(packageJson.name) }) .option('--use-npm') .option( - '-e, --example ', - 'an example to bootstrap the app with' + '-e, --example |', + ` + + An example to bootstrap the app with. You can use an example name + from the official Next.js repo or a GitHub URL. The URL can use + any branch and/or subdirectory +` + ) + .option( + '--example-path ', + ` + + In a rare case, your GitHub URL might contain a branch name with + a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar). + In this case, you must specify the path to the example separately: + --example-path foo/bar +` ) .allowUnknownOption() .parse(process.argv) @@ -89,6 +104,7 @@ async function run() { example: (typeof program.example === 'string' && program.example.trim()) || undefined, + examplePath: program.examplePath, }) } diff --git a/test/integration/create-next-app/index.test.js b/test/integration/create-next-app/index.test.js index 5b1eef06ba0f..c60777c3e85d 100644 --- a/test/integration/create-next-app/index.test.js +++ b/test/integration/create-next-app/index.test.js @@ -81,4 +81,77 @@ describe('create next app', () => { fs.existsSync(path.join(cwd, projectName, '.gitignore')) ).toBeTruthy() }) + + it('should allow example with GitHub URL', async () => { + const projectName = 'github-app' + const res = await run( + projectName, + '--example', + 'https://github.com/zeit/next-learn-demo/tree/master/1-navigate-between-pages' + ) + + expect(res.exitCode).toBe(0) + expect( + fs.existsSync(path.join(cwd, projectName, 'package.json')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/index.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/about.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, '.gitignore')) + ).toBeTruthy() + }) + + it('should allow example with GitHub URL and example-path', async () => { + const projectName = 'github-example-path' + const res = await run( + projectName, + '--example', + 'https://github.com/zeit/next-learn-demo/tree/master', + '--example-path', + '1-navigate-between-pages' + ) + + expect(res.exitCode).toBe(0) + expect( + fs.existsSync(path.join(cwd, projectName, 'package.json')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/index.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/about.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, '.gitignore')) + ).toBeTruthy() + }) + + it('should use --example-path over the file path in the GitHub URL', async () => { + const projectName = 'github-example-path-2' + const res = await run( + projectName, + '--example', + 'https://github.com/zeit/next-learn-demo/tree/master/1-navigate-between-pages', + '--example-path', + '1-navigate-between-pages' + ) + + expect(res.exitCode).toBe(0) + expect( + fs.existsSync(path.join(cwd, projectName, 'package.json')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/index.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, 'pages/about.js')) + ).toBeTruthy() + expect( + fs.existsSync(path.join(cwd, projectName, '.gitignore')) + ).toBeTruthy() + }) })