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 next export for static builds #1576

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ npm-debug.log
# coverage
.nyc_output
coverage

# osx
.DS_Store
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you should remove this, add it in your ~/.gitconfig

Copy link
Contributor Author

@matthewmueller matthewmueller Mar 31, 2017

Choose a reason for hiding this comment

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

i sort of feel like it's the project's job to prevent stuff like this from getting committed, but i don't care much either way 😄. let's see what others think

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed with Matthew

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree it should always be in your global gitignore

1 change: 1 addition & 0 deletions bin/next
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const commands = new Set([
'init',
'build',
'start',
'export',
defaultCommand
])

Expand Down
58 changes: 58 additions & 0 deletions bin/next-export
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node
import { resolve, join } from 'path'
import { existsSync } from 'fs'
import parseArgs from 'minimist'
import Build from '../server/build'
import Export from '../server/export'
import { printAndExit } from '../lib/utils'

process.env.NODE_ENV = process.env.NODE_ENV || 'production'

const argv = parseArgs(process.argv.slice(2), {
alias: {
h: 'help',
r: 'root'
},
boolean: ['h']
})

if (argv.help) {
console.log(`
Description
Compiles and exports the application to a static website

Usage
$ next export <dir>
$ next export --root <root> <dir>

<dir> represents where the static directory will go.
If no directory is provided, <dir> will be <root>/build.

<root> represents where the compiled <dir> folder should go.
If no directory is provided, .next will be created in the current directory.
`)
process.exit(0)
}

const root = resolve(argv['root'] || '.')
const dir = resolve(root, argv._[0] || 'build')

// Check if pages dir exists and warn if not
if (!existsSync(root)) {
printAndExit(`> No such directory exists as the project root: ${root}`)
}

if (!existsSync(join(root, 'pages'))) {
if (existsSync(join(root, '..', 'pages'))) {
printAndExit('> No `pages` directory found. Did you mean to run `next` in the parent (`../`) directory?')
}

printAndExit('> Couldn\'t find a `pages` directory. Please create one under the project root')
}

Build(root)
.then(() => Export({ dir, root }))
.catch((err) => {
console.error(err)
process.exit(1)
})
10 changes: 9 additions & 1 deletion client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ if (!window.Promise) {

const {
__NEXT_DATA__: {
exported,
component,
errorComponent,
props,
Expand All @@ -35,7 +36,11 @@ let lastAppProps
export const router = createRouter(pathname, query, getURL(), {
Component,
ErrorComponent,
err
err,
formatURL: exported && function (buildId, route) {
route = route && route.replace(/\/$/, '')
return route + '/index.json'
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe I'm missing something but if route is falsy then we are returning incorrect routes like undefined/index.js

Copy link
Contributor

Choose a reason for hiding this comment

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

Probably better rewritten as

return route
  ? route.replace(/\/$/, '') + '/index.json'
  : '/index.json'

(also doesn't reassign the function argument)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep, good catch!

}
})

const headManager = new HeadManager()
Expand Down Expand Up @@ -75,6 +80,9 @@ async function doRender ({ Component, props, hash, err, emitter }) {
// fetch props if ErrorComponent was replaced with a page component by HMR
const { pathname, query } = router
props = await loadGetInitialProps(Component, { err, pathname, query })
} else if (exported) {
const { pathname, query } = router
props = await loadGetInitialProps(Component, { err, pathname, query })
Copy link

@bringking bringking Apr 4, 2017

Choose a reason for hiding this comment

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

should this be loadGetStaticInitialProps, or since this is on the client, the branch condition isn't necessary

}

if (emitter) {
Expand Down
32 changes: 32 additions & 0 deletions examples/static-export/pages/about.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Component } from 'react'
import Router from 'next/router'

export default class About extends Component {
constructor (props) {
super(props)
this.state = {}
}

static async getInitialProps ({ build }) {
// this will get triggered on export!
// useful for pulling in markdown files
// during the build and doing other
// custom logic
if (build) return {}

// Errors during the build will halt the build
// Errors on the client-side will get routed to "An unexpected error has occurred."
// throw new Error("can't build on client-side!")

return {}
}

render () {
return (
<div>
<h2>About Me</h2>
<a onClick={() => Router.push('/')}>Go Back to Index</a>
</div>
)
}
}
37 changes: 37 additions & 0 deletions examples/static-export/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Component } from 'react'
import Button from '../ui/button'
import Link from 'next/link'

export default class Index extends Component {
constructor (props) {
super(props)
this.state = {
answer: ''
}
}

static async getInitialProps ({ pathname }) {
return {
title: 'Hello Static Stack!'
}
}

render () {
return (
<div>
<h2>{this.props.title}</h2>
<Link href='/about'><a>Learn more about me</a></Link>
<br />
<br />
<Button onClick={() => this.setState({ answer: 'yes.' })}>
Did Rehydration Work?
</Button>
<br />
<br />
<div>{this.state.answer}</div>
<br />
<Link href='/movies'><a>Check out the movies</a></Link>
</div>
)
}
}
19 changes: 19 additions & 0 deletions examples/static-export/pages/movies/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

import { Component } from 'react'
import Link from 'next/link'

export default class Movies extends Component {
constructor (props) {
super(props)
this.state = {}
}

render (props) {
return (
<div>
<h2>Some of my favorite movies</h2>
<Link href='/'><a>Go Back to Index</a></Link>
</div>
)
}
}
3 changes: 3 additions & 0 deletions examples/static-export/ui/button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default ({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
)
10 changes: 7 additions & 3 deletions lib/router/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { _notifyBuildIdMismatch } from './'
const webpackModule = module

export default class Router {
constructor (pathname, query, as, { Component, ErrorComponent, err } = {}) {
constructor (pathname, query, as, { Component, ErrorComponent, err, formatURL } = {}) {
// represents the current component key
this.route = toRoute(pathname)

Expand All @@ -22,6 +22,7 @@ export default class Router {

// Handling Router Events
this.events = mitt()
this.formatURL = formatURL || this.formatURL

this.prefetchQueue = new PQueue({ concurrency: 2 })
this.ErrorComponent = ErrorComponent
Expand Down Expand Up @@ -333,10 +334,13 @@ export default class Router {
return promise
}

formatURL (buildId, route) {
return `/_next/${encodeURIComponent(buildId)}/pages${route}`
}

doFetchRoute (route) {
const { buildId } = window.__NEXT_DATA__
const url = `/_next/${encodeURIComponent(buildId)}/pages${route}`

const url = this.formatURL(buildId, route)
return fetch(url, {
method: 'GET',
credentials: 'same-origin',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"cross-spawn": "5.1.0",
"del": "2.2.2",
"friendly-errors-webpack-plugin": "1.5.0",
"fs-promise": "2.0.2",
"glob-promise": "3.1.0",
"htmlescape": "1.1.1",
"http-status": "1.0.1",
Expand Down
4 changes: 2 additions & 2 deletions server/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ export class NextScript extends Component {

getChunkScript (filename, additionalProps = {}) {
const { __NEXT_DATA__ } = this.context._documentProps
let { buildStats } = __NEXT_DATA__
let { buildStats, exported } = __NEXT_DATA__
const hash = buildStats ? buildStats[filename].hash : '-'

return (
<script
type='text/javascript'
src={`/_next/${hash}/${filename}`}
src={exported ? `/${filename}` : `/_next/${hash}/${filename}`}
{...additionalProps}
/>
)
Expand Down
139 changes: 139 additions & 0 deletions server/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Module dependencies
*/

import { join, basename, dirname, extname, relative, resolve as pathResolve } from 'path'
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
import { loadGetInitialProps } from '../lib/utils'
import Head, { defaultHead } from '../lib/head'
import { Router } from '../lib/router'
import { createElement } from 'react'
import readPage from './read-page'
import glob from 'glob-promise'
import mkdir from 'mkdirp-then'
import resolve from './resolve'
import App from '../lib/app'
import fs from 'fs-promise'

/**
* Export to Static HTML
*/

export default async function Export ({
staticMarkup = false,
root = process.cwd(),
dir = 'build',
dev = false
} = {}) {
const nextPath = join(root, '.next')
const pageDir = join(nextPath, 'dist', 'pages')
const exportPath = pathResolve(root, dir)

let pages = await glob(join(pageDir, '**', '*.js'))
pages = pages.filter(page => basename(page)[0] !== '_')

// load the top-level document
const Document = require(join(nextPath, 'dist', 'pages', '_document.js')).default
await mkdir(exportPath)

// copy over the common bundle
await fs.copy(join(nextPath, 'app.js'), join(exportPath, 'app.js'))

// build all the pages
await Promise.all(pages.map(async (page) => {
const pathname = toRoute(pageDir, page)
const pageName = getPageName(pageDir, page)
const Component = require(page).default
const query = {}
const ctx = { pathname, query, build: true }
const bundlePath = await resolve(join(nextPath, 'bundles', 'pages', pageName))

const [
props,
component,
errorComponent
] = await Promise.all([
loadGetInitialProps(Component, ctx),
readPage(bundlePath),
readPage(join(nextPath, 'bundles', 'pages', '_error'))
])

const renderPage = () => {
const app = createElement(App, {
Component,
props,
// TODO: figure out if err is relevant with `next build`
// err: dev ? err : null,
router: new Router(pathname, query)
})

const render = staticMarkup ? renderToStaticMarkup : renderToString

let html
let head
try {
html = render(app)
} finally {
head = Head.rewind() || defaultHead()
}
return { html, head }
}

const docProps = await loadGetInitialProps(Document, Object.assign(ctx, { renderPage }))
const doc = createElement(Document, Object.assign({
__NEXT_DATA__: {
component: component,
errorComponent,
props,
pathname,
query,
// TODO: figure out if we need/want build stats when we export
// buildId,
// buildStats,
exported: true
// TODO: needed for static builds?
// err: (err && dev) ? errorToJSON(err) : null
},
dev,
staticMarkup
}, docProps))

const html = '<!DOCTYPE html>' + renderToStaticMarkup(doc)

// write files
const htmlPath = join(exportPath, pathname)
await mkdir(htmlPath)
await fs.writeFile(join(htmlPath, 'index.html'), html)

// copy component bundle over
await fs.copy(bundlePath, join(htmlPath, 'index.json'))
}))
}

// Turn the path into a route
//
// e.g.
// - index.js => /
// - about.js => /about
// - movies/index.js => /movies
function toRoute (pageDir, entry) {
const page = '/' + relative(pageDir, entry)
const base = basename(page, extname(page))
if (base === 'index') {
const dir = dirname(page)
return dir === '/' ? '/' : dir
} else {
return '/' + base
}
}

function getPageName (pageDir, entry) {
const page = '/' + relative(pageDir, entry)
const base = basename(page, extname(page))
if (base === 'index') {
const dir = basename(dirname(page))
return dir === '' ? 'index' : dir
} else {
return base
}
}