Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --example=<github-url> to create-next-app #10226

Merged
merged 11 commits into from Feb 27, 2020
98 changes: 82 additions & 16 deletions packages/create-next-app/create-app.ts
Expand Up @@ -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'
Expand All @@ -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)
}
}
}

Expand All @@ -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')
Expand Down
61 changes: 55 additions & 6 deletions packages/create-next-app/helpers/examples.ts
Expand Up @@ -2,20 +2,69 @@ import got from 'got'
import promisePipe from 'promisepipe'
import tar from 'tar'

export async function hasExample(name: string): Promise<boolean> {
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<boolean> {
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<void> {
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<void> {
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}`])
)
Expand Down
20 changes: 18 additions & 2 deletions packages/create-next-app/index.ts
Expand Up @@ -21,8 +21,23 @@ const program = new Commander.Command(packageJson.name)
})
.option('--use-npm')
.option(
'-e, --example <example-path>',
'an example to bootstrap the app with'
'-e, --example <name>|<github-url>',
`

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 <path-to-example>',
`

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
`
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
)
.allowUnknownOption()
.parse(process.argv)
Expand Down Expand Up @@ -89,6 +104,7 @@ async function run() {
example:
(typeof program.example === 'string' && program.example.trim()) ||
undefined,
examplePath: program.examplePath,
})
}

Expand Down
73 changes: 73 additions & 0 deletions test/integration/create-next-app/index.test.js
Expand Up @@ -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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is working now but will fail if we update https://github.com/zeit/next-learn-demo to have different example names (e.g. if we rename 1-navigate-between-pages or remove about.js). Should we create another repository just for testing purposes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use zeit/next.js imo.

)

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