Skip to content

Commit

Permalink
Add --example=<github-url> to create-next-app (#10226)
Browse files Browse the repository at this point in the history
* Initial support for URLs

* Added folder support

* Added --example-path

* Bug fix

* Also install deps

* Updated error message

* Bug fix and replace the file path

* Added tests

Co-authored-by: Shu Uesugi <shu@chibicode.com>
  • Loading branch information
Luis Alvarez D and chibicode committed Feb 27, 2020
1 parent 0b88d17 commit 75559f1
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 24 deletions.
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
`
)
.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'
)

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

0 comments on commit 75559f1

Please sign in to comment.