1
1
mirror of https://github.com/c8r/x0.git synced 2024-10-04 00:58:09 +03:00

Rewrite for v5

This commit is contained in:
Brent Jackson 2018-05-19 17:06:12 -04:00
parent c9f97e2b6f
commit dad4e808b7
24 changed files with 741 additions and 910 deletions

206
README.md
View File

@ -22,9 +22,8 @@ npm install -g @compositor/x0
- Renders static HTML
- Renders JS bundles
- Works with CSS-in-JS libraries like [styled-components][sc] and [glamorous][glamorous]
- Support for routing with [react-router][react-router]
- Automatic file system based routing
- Support for async data fetching
- Support for code splitting with [React Loadable][react-loadable]
\* Custom [webpack configuration](#webpack) is required for components that rely on webpack-based features
@ -32,18 +31,20 @@ npm install -g @compositor/x0
## Isolated development environment
```sh
x0 src/App.js
x0 components
```
Options:
```
-o --open Open dev server in default browser
-p --port Set custom port for dev server
-o --open Open dev server in default browser
-p --port Custom port for dev server
-t --template Path to custom HTML template
--webpack Path to custom webpack configuration
```
```sh
x0 src/App.js -op 8080
x0 components -op 8080
```
@ -52,36 +53,36 @@ x0 src/App.js -op 8080
Render static HTML and client-side bundle
```sh
x0 build src/App.js --out-dir site
x0 build components
```
Render static HTML without bundle
```sh
x0 build src/App.js --out-dir site --static
x0 build components --static
```
Options
```
-d --out-dir Directory to save index.html and bundle.js to
-s --static Only render static HTML (no client-side JS)
-d --out-dir Output directory (default dist)
-s --static Output static HTML without JS bundle
-t --template Path to custom HTML template
--webpack Path to custom webpack configuration
```
## Fetching Data
Use the `getInitialProps` static method to fetch data for static rendering.
This method was inspired by [Next.js][nextjs] but only works for static rendering.
Use the async `getInitialProps` static method to fetch data for static rendering.
This method was inspired by [Next.js][nextjs].
```jsx
const App = props => (
<h1>Hello {props.data}</h1>
)
App.getInitialProps = async ({
Component,
pathname
}) => {
App.getInitialProps = async () => {
const fetch = require('isomorphic-fetch')
const res = await fetch('http://example.com/data')
const data = await res.json()
@ -92,145 +93,92 @@ App.getInitialProps = async ({
## CSS-in-JS
x0 supports server-side rendering for [styled-components][sc], [glamor][glamor], [glamorous][glamorous], and [fela][fela].
To enable CSS rendering for static output, use the `cssLibrary` option
x0 supports server-side rendering for [styled-components][sc] with zero configuration.
To enable CSS rendering for static output, ensure that `styled-components` is installed as a dependency in your `package.json`
```sh
x0 build src/App.js --cssLibrary="styled-components"
```
Available options:
- [`styled-components`][sc]
- [`glamorous`][glamorous]
- [`glamor`][glamor]
- [`fela`][fela]
## Head content
Head elements such as `<title>`, `<meta>`, and `<style>` can be rendered at the beginning of a component.
Browsers should handle this correctly since the `<head>` and `<body>` elements are optional in HTML 5.
```jsx
const App = props => (
<React.Fragment>
<title>Hello x0</title>
<style dangerouslySetInnerHTML={{
__html: 'body{font-family:-apple-system,BlinkMacSystemFont,sans-serif}'
}} />
<h1>Hello x0</h1>
</React.Fragment>
)
```json
"dependencies": {
"styled-components": "^3.2.6"
}
```
## Configuration
Default props can be passed to x0 in a `package.json` field named `x0`.
Default options can be set in the `x0` field in `package.json`.
```json
"x0": {
"static": true,
"outDir": "site",
"title": "Hello",
"count": 0
}
```
### Routing
## Head content
To render multiple pages and use routing, add a `routes` array to the `package.json` configuration object.
Head elements such as `<title>`, `<meta>`, and `<style>` can be configured with the `x0` field in `package.json`.
```json
"x0": {
"routes": [
"/",
"/about"
"title": "My Site",
"meta": [
{ "name": "twitter:card", "content": "summary" }
{ "name": "twitter:image", "content": "kitten.png" }
],
"links": [
{
"rel": "stylesheet",
"href": "https://fonts.googleapis.com/css?family=Roboto"
}
]
}
```
```sh
x0 build src/App.js --static --out-dir site
```
## Custom HTML Template
The current route will be passed to the component as `props.pathname`.
This can be used with [react-router][react-router]'s StaticRouter and BrowserRouter components.
```jsx
// Example with react-router
import React from 'react'
import {
StaticRouter,
BrowserRouter,
Route,
Link
} from 'react-router-dom'
import Home from './Home'
import About from './About'
// universal router component
const Router = typeof document !== 'undefined'
? BrowserRouter
: StaticRouter
const App = props => (
<Router
basename={props.basename}
location={props.pathname}>
<nav>
<Link to='/'>Home</Link>
<Link to='/about'>About</Link>
</nav>
<Route
exact
path='/'
render={() => <Home {...props} />}
/>
<Route
path='/about'
render={() => <About {...props} />}
/>
</Router>
)
```
### Code Splitting
To split client side bundles when rendering a static site,
x0 supports [React Loadable][react-loadable] with no additional setup needed.
```jsx
// example of using React Loadable
import React from 'react'
import Loadable from 'react-loadable'
const About = Loadable({
loading: () => <div>loading...</div>,
loader: () => import('./About')
})
const App = props => (
<div>
<h1>Hello</h1>
<About />
</div>
)
```
### Proxy
If you want to proxy requests you can configure it using the `x0` key in your `package.json`.
This can be useful when you're running a local api server during development.
The following example proxies all `/api` requests to `http://localhost:3000`.
A custom HTML template can be passed as the `template` option.
```json
"x0": {
"/api": "http://localhost:3000"
"template": "./html.js"
}
```
```js
// example template
module.exports = ({
html,
css,
scripts,
title,
meta = [],
links = [],
static: isStatic
}) => `<!DOCTYPE html>
<head>
<title>{title}</title>
${css}
</head>
<div id=root>${html}</div>
${scripts}
`
```
### Routing
x0 creates routes based on the file system, using [react-router][react-router].
To set the base URL for static builds, use the `basename` option.
```json
"x0": {
"basename": "/my-site"
}
```
### webpack
Custom webpack loaders can be used by creating a partial `webpack.config.js` file and passing it to the `--config` option.
Webpack configuration files named `webpack.config.js` will automatically be merged with the built-in configuration, using [webpack-merge][webpack-merge].
To use a custom filename, pass the file path to the `--webpack` flag.
```js
// webpack.config.js example
@ -243,14 +191,8 @@ module.exports = {
}
```
```sh
x0 build App.js --config webpack.config.js
```
See the [example](examples/webpack-config).
x0 uses [webpack-merge][webpack-merge], which means that other webpack features, such as plugins, should also work.
#### Related
- [Create React App](https://github.com/facebookincubator/create-react-app)

View File

@ -1,99 +0,0 @@
#!/usr/bin/env node
const path = require('path')
const meow = require('meow')
const { pkg } = require('read-pkg-up').sync()
const openBrowser = require('react-dev-utils/openBrowser')
const ora = require('ora')
const chalk = require('chalk')
const x0Pkg = require('../package.json')
require('update-notifier')({ pkg: x0Pkg }).notify()
const cli = meow(`
Usage:
$ x0 dev src/App.js
$ x0 build src/App.js
Options:
-d --out-dir Output directory for static build
-s --static Render static HTML without client-side JS
-p --port Port for dev server
-o --open Open dev server in default browser
-c --config Pass a custom weback config to merge with the default config
--proxy Proxy requests to another server (only for dev)
--proxy-path Path to proxy, default: /api
`, {
alias: {
d: 'outDir',
s: 'static',
p: 'port',
o: 'open',
c: 'config',
h: 'help',
v: 'version',
}
})
const [ cmd, file ] = cli.input
const options = Object.assign({}, pkg ? pkg.x0 : {}, cli.flags)
const absolute = f => f
? path.isAbsolute(f) ? f : path.join(process.cwd(), f)
: null
const filename = absolute(file || cmd)
if (options.config) {
options.config = absolute(options.config)
}
console.log(chalk.black.bgCyan(' x0 '), chalk.cyan('@compositor/x0'), '\n')
const spinner = ora().start()
switch (cmd) {
case 'build':
spinner.start('building static site')
const build = require('../lib/static')
build(filename, options)
.then(async html => {
if (!options.outDir) {
return console.log(html)
}
spinner.succeed(`static site saved to ${options.outDir}`)
})
.catch(err => {
spinner.fail('Error')
console.log(err)
process.exit(1)
})
break
case 'dev':
default:
spinner.start('starting dev server')
const dev = require('../lib/dev')
dev(filename, options)
.then(server => {
const { port } = server.listeningApp.address()
spinner.succeed(`dev server listening at http://localhost:${port}`)
if (options.open) {
openBrowser(`http://localhost:${port}`)
}
})
.catch(err => {
spinner.fail(err)
process.exit(1)
})
break
}

159
cli.js Executable file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const meow = require('meow')
const findup = require('find-up')
const readPkg = require('read-pkg-up').sync
const openBrowser = require('react-dev-utils/openBrowser')
const chalk = require('chalk')
const clipboard = require('clipboardy')
const config = require('pkg-conf').sync('x0')
const pkg = readPkg().pkg
const log = (...args) => {
console.log(
chalk.black.bgCyan(' x0 '),
...args
)
}
log.error = (...args) => {
console.log(
chalk.black.bgRed(' err '),
chalk.red(...args)
)
}
const cli = meow(`
Usage
Dev Server
x0 pages
Build
x0 build pages
Options
--webpack Path to webpack config file
Dev Server
-o --open Open dev server in default browser
-p --port Port for dev server
Build
-d --out-dir Output directory (default dist)
-s --static Output static HTML without JS bundle
-t --template Path to custom HTML template
`, {
flags: {
// dev
open: {
type: 'boolean',
alias: 'o'
},
port: {
type: 'string',
alias: 'p'
},
// build
outDir: {
type: 'string',
alias: 'd'
},
static: {
type: 'boolean',
},
template: {
type: 'string',
alias: 't'
},
// shared
config: {
type: 'string',
alias: 'c'
},
scope: {
type: 'string',
},
webpack: {
type: 'string',
},
}
})
const [ cmd, file ] = cli.input
const input = path.resolve(file || cmd)
const stats = fs.statSync(input)
const dirname = stats.isDirectory() ? input : path.dirname(input)
const filename = stats.isDirectory() ? null : input
const opts = Object.assign({
input,
dirname,
filename,
stats,
outDir: 'dist',
basename: '/',
scope: {},
pkg,
}, config, cli.flags)
opts.outDir = path.resolve(opts.outDir)
if (opts.config) opts.config = path.resolve(opts.config)
if (opts.webpack) {
opts.webpack = require(path.resolve(opts.webpack))
} else {
const webpackConfig = findup.sync('webpack.config.js', { cwd: dirname })
if (webpackConfig) opts.webpack = require(webpackConfig)
}
if (opts.template) {
opts.template = require(path.resolve(opts.template))
}
const handleError = err => {
log.error(err)
process.exit(1)
}
switch (cmd) {
case 'build':
log('building static site')
const { build } = require('.')
build(opts)
.then(res => {
log('site saved to ' + opts.outDir)
})
.catch(handleError)
break
case 'dev':
default:
log('starting dev server')
const { dev } = require('.')
dev(opts)
.then(res => {
const { port } = res.options
const url = `http://localhost:${port}`
log(
'dev server listening on',
chalk.green(url),
chalk.gray('(copied to clipboard)')
)
clipboard.write(url)
if (opts.open) {
openBrowser(url)
}
})
.catch(handleError)
break
}
require('update-notifier')({
pkg: require('./package.json')
}).notify()

9
docs/index.js Normal file
View File

@ -0,0 +1,9 @@
import React from 'react'
export default class extends React.Component {
render () {
return (
<h1>Hello x0</h1>
)
}
}

View File

@ -1 +1,2 @@
module.exports = require('./lib')
module.exports.dev = require('./lib/dev')
module.exports.build = require('./lib/build')

189
lib/build.js Normal file
View File

@ -0,0 +1,189 @@
const fs = require('fs-extra')
const path = require('path')
const webpack = require('webpack')
const MiniHTMLWebpackPlugin = require('mini-html-webpack-plugin')
const { generateJSReferences } = require('mini-html-webpack-plugin')
const merge = require('webpack-merge')
const React = require('react')
const { renderToString, renderToStaticMarkup } = require('react-dom/server')
const { StaticRouter } = require('react-router-dom')
const semver = require('semver')
const baseConfig = require('./config')
const createTemplate = require('./createTemplate')
const getApp = opts => {
const config = merge(baseConfig, opts.webpack)
config.mode = 'development'
config.entry = path.join(__dirname, './entry.js')
config.output= {
path: opts.tempdir,
filename: 'App.js',
libraryTarget: 'umd'
}
config.target = 'node'
const compiler = webpack(config)
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
reject(err)
return
}
const App = require(
path.resolve(opts.tempdir, './App.js')
)
resolve(App)
})
})
}
const STYLED_COMPONENTS_VERSION = '>=3.0'
const EMOTION_VERSION = '>=9.0'
const getCSSLibrary = opts => {
if (opts.cssLibrary) return opts.cssLibrary
if (!opts.pkg) return null
const deps = Object.assign({},
opts.pkg.devDependencies,
opts.pkg.dependencies
)
if (deps['styled-components']) {
const scVersion = semver.coerce(deps['styled-components'])
if (!semver.satisfies(scVersion, STYLED_COMPONENTS_VERSION)) return null
return 'styled-components'
}
if (deps.emotion) {
const emotionVersion = semver.coerce(deps.emotion)
if (!semver.satisfies(emotionVersion, EMOTION_VERSION)) return null
return 'emotion'
}
return null
}
const renderHTML = ({
opts,
routes,
App,
props,
path
}) => {
const render = opts.static ? renderToStaticMarkup : renderToString
const cssLibrary = getCSSLibrary(opts)
const app = React.createElement(App.default, { routes, path })
let html
let css
switch (cssLibrary) {
case 'styled-components':
const { ServerStyleSheet } = require('styled-components')
const sheet = new ServerStyleSheet()
html = render(
sheet.collectStyles(
React.createElement(App.default, { routes, path })
)
)
css = sheet.getStyleTags()
return { path, html, css, props }
case 'emotion':
const { renderStylesToString } = require('emotion-server')
html = renderStylesToString(
render(app)
)
return { path, html, props }
default:
html = render(app)
return { path, html, props }
}
}
const remove = filename => {
fs.remove(filename, err => {
if (err) console.log(err)
})
}
const getRoutes = async (App) => {
const baseRoutes = await App.getRoutes()
// todo clean up
const dynamicRoutes = []
baseRoutes.forEach(route => {
if (route.props.routes) {
route.props.routes.forEach(subroute => dynamicRoutes.push(
Object.assign({}, route, subroute)
))
}
})
const routes = [
...baseRoutes.filter(route => !route.props.routes),
...dynamicRoutes
]
return routes
}
module.exports = async (opts) => {
// mutation
baseConfig.resolve.modules.unshift(
path.join(opts.dirname, 'node_modules'),
opts.dirname
)
// mutation
baseConfig.plugins.push(
new webpack.DefinePlugin({
DEV: JSON.stringify(false),
OPTIONS: JSON.stringify(opts),
DIRNAME: JSON.stringify(opts.dirname)
})
)
opts.tempdir = path.join(opts.outDir, 'TEMP')
if (!fs.existsSync(opts.outDir)) fs.mkdirSync(opts.outDir)
if (!fs.existsSync(opts.tempdir)) fs.mkdirSync(opts.tempdir)
const App = await getApp(opts)
const routes = await getRoutes(App)
const template = createTemplate(opts)
const pages = routes.map(route => renderHTML(
Object.assign({}, route, {
opts,
App,
routes,
})
))
const config = merge(baseConfig, opts.webpack)
config.entry = path.join(__dirname, './entry')
config.output = {
path: opts.outDir,
filename: 'bundle.js',
publicPath: opts.basename
}
// push per route/page
pages.forEach(({ path, html, css, props }) => {
config.plugins.push(
new MiniHTMLWebpackPlugin({
filename: path + '/index.html',
context: Object.assign({}, opts, props, { html, css }),
template
})
)
})
const compiler = webpack(config)
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
reject(err)
return
}
remove(opts.tempdir)
resolve(stats)
})
})
}

52
lib/config.js Normal file
View File

@ -0,0 +1,52 @@
const path = require('path')
const babel = {
presets: [
'babel-preset-env',
'babel-preset-stage-0',
'babel-preset-react',
].map(require.resolve),
plugins: [
'babel-plugin-macros',
'babel-plugin-transform-runtime'
].map(require.resolve)
}
const rules = [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: require.resolve('babel-loader'),
options: babel
}
},
{
test: /\.js$/,
exclude: path.resolve(__dirname, '../node_modules'),
include: path.resolve(__dirname),
use: {
loader: require.resolve('babel-loader'),
options: babel
}
},
]
// common config
module.exports = {
stats: 'none',
resolve: {
modules: [
__dirname,
path.join(__dirname, '../node_modules'),
'node_modules'
]
},
module: {
rules
},
node: {
fs: 'empty'
},
plugins: []
}

18
lib/createTemplate.js Normal file
View File

@ -0,0 +1,18 @@
const { generateJSReferences } = require('mini-html-webpack-plugin')
const { minify } = require('html-minifier')
const defaultTemplate = require('./template')
module.exports = opts => {
const template = opts.template || defaultTemplate
return context => {
const scripts = generateJSReferences(context.js, context.publicPath)
return minify(
template(Object.assign({}, context, {
scripts
})),
{
collapseWhitespace: true
}
)
}
}

71
lib/dev.js Normal file
View File

@ -0,0 +1,71 @@
const path = require('path')
const webpack = require('webpack')
const serve = require('webpack-serve')
const history = require('connect-history-api-fallback')
const convert = require('koa-connect')
const MiniHTMLWebpackPlugin = require('mini-html-webpack-plugin')
const merge = require('webpack-merge')
const baseConfig = require('./config')
const createTemplate = require('./createTemplate')
const dev = {
hot: true,
logLevel: 'error',
clientLogLevel: 'none',
stats: 'errors-only'
}
module.exports = async (opts) => {
const config = merge(baseConfig, opts.webpack)
const template = createTemplate(opts)
config.mode = 'development'
config.entry = path.join(__dirname, './entry')
config.output= {
path: path.join(process.cwd(), 'dev'),
filename: 'dev.js',
publicPath: '/'
}
config.resolve.modules.unshift(
opts.dirname,
path.join(opts.dirname, 'node_modules')
)
config.plugins.push(
new webpack.DefinePlugin({
DEV: JSON.stringify(true),
OPTIONS: JSON.stringify(opts),
DIRNAME: JSON.stringify(opts.dirname),
})
)
config.plugins.push(
new MiniHTMLWebpackPlugin({
context: opts,
template
})
)
const serveOpts = {
config,
dev,
logLevel: 'error',
port: opts.port,
hot: { logLevel: 'error' },
add: (app, middleware, options) => {
app.use(convert(history({})))
}
}
return new Promise((resolve, reject) => {
serve(serveOpts)
.then(server => {
server.compiler.hooks.done.tap({ name: 'x0' }, () => {
resolve(server)
})
})
.catch(reject)
})
}

View File

@ -1,23 +0,0 @@
const React = require('react')
const h = React.createElement
const Catch = require('./Catch')
class App extends React.Component {
constructor (props) {
super(props)
this.state = {
Component: props.Component,
}
}
render () {
const { Component } = this.state
return h(Catch, null,
h(Component, this.props)
)
}
}
module.exports = App

View File

@ -1,38 +0,0 @@
const React = require('react')
class Catch extends React.Component {
constructor () {
super()
this.state = {
err: null
}
}
componentDidCatch (err) {
console.error(err)
this.setState({ err })
}
componentWillReceiveProps (next) {
this.setState({ err: null })
}
render () {
if (this.state.err) {
return React.createElement('pre', {
style: {
fontFamily: '"Roboto Mono", Menlo, monospace',
whiteSpace: 'pre-wrap',
padding: 32,
color: 'white',
backgroundColor: 'red'
}
}, this.state.err.toString())
}
return React.Children.only(this.props.children)
}
}
module.exports = Catch

View File

@ -1,49 +0,0 @@
const path = require('path')
const webpack = require('webpack')
// dev webpack config
module.exports = {
mode: 'development',
devtool: 'source-map',
entry: [
path.join(__dirname, './entry'),
],
output: {
path: __dirname,
filename: 'dev.js',
publicPath: '/'
},
resolve: {
modules: [
path.join(__dirname, '../../node_modules'),
'node_modules'
]
},
module: {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
use: {
loader: require.resolve('babel-loader'),
options: {
presets: [
'babel-preset-env',
'babel-preset-stage-0',
'babel-preset-react'
].map(require.resolve),
plugins: [
require.resolve('babel-plugin-transform-runtime')
]
}
}
}
]
},
node: {
fs: 'empty'
},
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}

View File

@ -1,26 +0,0 @@
const React = require('react')
const { render } = require('react-dom')
const App = require('./App')
const div = typeof APP !== 'undefined' ? APP : document.body
const id = require.resolve(COMPONENT)
const req = require(COMPONENT)
const Component = req.default || req
const props = Object.assign({
id,
Component
}, PROPS)
const app = render(React.createElement(App, props), div)
if (module.hot) {
module.hot.accept(id, function () {
const next = require(COMPONENT)
const NextComponent = next.default || next
app.setState({
Component: NextComponent
})
})
}

View File

@ -1,109 +0,0 @@
require('babel-register')({
presets: [
[ require.resolve('babel-preset-env'), {
targets: {
node: '8'
}
}],
require.resolve('babel-preset-stage-0'),
require.resolve('babel-preset-react')
],
plugins: [
'react-loadable/babel',
'babel-plugin-syntax-dynamic-import',
'babel-plugin-dynamic-import-node',
].map(require.resolve)
})
const path = require('path')
const webpack = require('webpack')
const DevServer = require('webpack-dev-server')
const merge = require('webpack-merge')
const config = require('./config')
const getWebpackApp = require('../static/webpackNodeApp')
const devOptions = {
hot: true,
historyApiFallback: {
index: '/dev'
},
overlay: true
}
const start = async (filename, options, config) => {
const Component = await getWebpackApp(filename, options)
const {
proxy,
port = 8000
} = options
const getProps = typeof Component.getInitialProps === 'function'
? Component.getInitialProps
: async () => null
const initialProps = await getProps(Object.assign({
Component
}, options))
const props = Object.assign({}, options, initialProps)
const defs = new webpack.DefinePlugin({
COMPONENT: JSON.stringify(filename),
PROPS: JSON.stringify(props)
})
config.plugins.push(defs)
devOptions.contentBase = path.dirname(filename)
if (proxy) {
devOptions.proxy = proxy
}
const compiler = webpack(config)
const server = new DevServer(compiler, devOptions)
return new Promise((resolve, reject) => {
compiler.plugin('done', () => {
resolve(server)
})
server.listen(port, err => {
if (err) throw err
})
})
}
module.exports = (filename, options = {}) => {
if (!filename) return
const dirname = path.dirname(filename)
const {
port = 8000
} = options
config.resolve.modules.unshift(
dirname,
path.join(process.cwd(), 'node_modules'),
path.join(dirname, 'node_modules'),
)
config.entry.push(
`webpack-dev-server/client?http://localhost:${port}`,
'webpack/hot/only-dev-server'
)
let mergedConfig = config
if (options.basename) {
config.output.publicPath = options.basename + '/'
}
if (options.config) {
const userConfig = require(options.config)
mergedConfig = merge(config, userConfig)
}
return start(filename, options, mergedConfig)
}

101
lib/entry.js Normal file
View File

@ -0,0 +1,101 @@
import path from 'path'
import React from 'react'
import { render, hydrate } from 'react-dom'
import {
StaticRouter,
BrowserRouter,
Route
} from 'react-router-dom'
const IS_CLIENT = typeof document !== 'undefined'
const req = require.context(DIRNAME, false, /\.(js|mdx|jsx)$/)
const { filename } = OPTIONS
const index = filename ? path.basename(filename, path.extname(filename)) : 'index'
const getComponents = req => req.keys().map(key => ({
key,
name: path.basename(key, path.extname(key)),
Component: req(key).default || req(key)
}))
.filter(component => !/^(\.|_)/.test(component.name))
.filter(component => typeof component.Component === 'function')
const initialComponents = getComponents(req)
const Router = IS_CLIENT ? BrowserRouter : StaticRouter
export const getRoutes = async (components = initialComponents) => {
const routes = await components.map(async ({ key, name, Component }) => {
const exact = name === index
let pathname = exact ? '/' : '/' + name
const props = Component.getInitialProps
? await Component.getInitialProps({ path: pathname })
: {}
if (IS_CLIENT) pathname = props.path || pathname
return {
key: name,
name,
path: pathname,
exact,
Component,
props
}
})
return Promise.all(routes)
}
export default class App extends React.Component {
state = this.props
render () {
const {
routes,
basename = '',
path = '/'
} = this.state
return (
<Router
context={{}}
basename={basename}
location={path}>
<React.Fragment>
{routes.map(({ Component, ...route }) => (
<Route
{...route}
render={props => (
<Component
{...props}
{...route.props}
/>
)}
/>
))}
</React.Fragment>
</Router>
)
}
}
let app
if (IS_CLIENT) {
const mount = DEV ? render : hydrate
const div = window.root || document.body.appendChild(
document.createElement('div')
)
getRoutes()
.then(routes => {
app = mount(<App routes={routes} />, div)
})
}
if (IS_CLIENT && module.hot) {
module.hot.accept(req.id, async () => {
const next = require.context(DIRNAME, false, /\.(js|mdx|jsx)$/)
const components = getComponents(next)
const routes = await getRoutes(components)
app.setState({ routes })
})
}

View File

@ -1,2 +0,0 @@
module.exports.dev = require('./dev')
module.exports.static = require('./static')

View File

@ -1,96 +0,0 @@
const path = require('path')
const webpack = require('webpack')
const MinifyPlugin = require('babel-minify-webpack-plugin')
const { ReactLoadablePlugin } = require('react-loadable/webpack')
const merge = require('webpack-merge')
const config = {
mode: 'production',
entry: [
path.join(__dirname, './entry')
],
output: {
filename: 'bundle.js',
publicPath: '/'
},
resolve: {
modules: [
path.join(__dirname, '../../node_modules'),
'node_modules'
]
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: require.resolve('babel-loader'),
options: {
presets: [
'babel-preset-env',
'babel-preset-stage-0',
'babel-preset-react'
].map(require.resolve),
plugins: [
require.resolve('babel-plugin-transform-runtime')
]
}
}
}
]
},
node: {
fs: 'empty'
},
plugins: [
new ReactLoadablePlugin({
filename: path.join(__dirname, './TMP/react-loadable.json'),
})
]
}
module.exports = (filename, options = {}) => {
if (options.static || !options.outDir) return
const dirname = path.dirname(filename)
config.output.path = path.join(process.cwd(), options.outDir)
if (options.basename) {
config.output.publicPath = options.basename + '/'
}
config.resolve.modules.unshift(
dirname,
path.join(process.cwd(), 'node_modules'),
path.join(dirname, 'node_modules')
)
config.plugins.push(
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
},
COMPONENT: JSON.stringify(filename)
})
)
let mergedConfig = config
if (options.config) {
const userConfig = require(options.config)
mergedConfig = merge(config, userConfig)
}
const compiler = webpack(mergedConfig)
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
console.log(err)
reject(err)
}
resolve(stats)
})
})
}

View File

@ -1,16 +0,0 @@
const React = require('react')
const { hydrate } = require('react-dom')
const Loadable = require('react-loadable')
const App = require(COMPONENT).default || require(COMPONENT)
const data = document.getElementById('__initial-props__').innerHTML
const props = JSON.parse(data)
const div = document.documentElement
Loadable.preloadReady()
.then(() => {
hydrate(
React.createElement(App, props),
div
)
})

View File

@ -1,59 +0,0 @@
const React = require('react')
const { renderToString } = require('react-dom/server')
const getSC = (Component, props) => {
const SC = require('styled-components')
const sheet = new SC.ServerStyleSheet()
renderToString(
sheet.collectStyles(
React.createElement(Component, props)
)
)
const tags = sheet.getStyleTags()
return tags
}
const getGlamor = (Component, props) => {
const glamor = require('glamor/server')
const { css } = glamor.renderStatic(() => (
renderToString(
React.createElement(Component, props)
)
))
const tag = `<style>${css}</style>`
return tag
}
const getFela = (Component, props) => {
if (!props.renderer) {
console.log('Warning: Fela static rendering requires a `renderer` to be passed through the `getInitialProps()` method.')
return ''
}
const fela = require('fela')
const felaDOM = require('fela-dom')
const renderer = props.renderer || fela.createRenderer()
renderToString(
React.createElement(Component, props)
)
const tag = felaDOM.renderToMarkup(renderer)
return tag
}
const libraries = {
'styled-components': getSC,
glamorous: getGlamor,
glamor: getGlamor,
fela: getFela,
}
const noop = () => ''
module.exports = (Component, props = {}) => {
const { cssLibrary } = props
const getCSS = libraries[cssLibrary] || noop
// style tag strings
const css = getCSS(Component, props)
return css
}

View File

@ -1,136 +0,0 @@
require('babel-register')({
presets: [
[ require.resolve('babel-preset-env'), {
targets: {
node: '8'
}
}],
require.resolve('babel-preset-stage-0'),
require.resolve('babel-preset-react')
],
plugins: [
'react-loadable/babel',
'babel-plugin-syntax-dynamic-import',
'babel-plugin-dynamic-import-node',
].map(require.resolve)
})
const fs = require('fs')
const path = require('path')
const React = require('react')
const { renderToString, renderToStaticMarkup } = require('react-dom/server')
const Loadable = require('react-loadable')
const { getBundles } = require('react-loadable/webpack')
const getWebpackApp = require('./webpackNodeApp')
const client = require('./client')
const getCSS = require('./getCSS')
const createHTML = ({
body,
css = '',
initialProps,
scripts = []
}) => ([
'<!DOCTYPE html><meta charset="utf-8">',
css,
body,
propsScript(initialProps),
scripts.map(src => `<script src="${src}"></script>`).join('')
].join(''))
const propsScript = initialProps =>
initialProps ? `<script id="__initial-props__" type="application/json">${JSON.stringify(initialProps)}</script>` : ''
const render = (Component, props, isStatic, modules = []) =>
(isStatic ? renderToStaticMarkup : renderToString)(
React.createElement(Loadable.Capture, {
report: mod => modules.push(mod)
},
React.createElement(Component, props)
)
)
const renderHTML = async (Component, options) => {
const modules = []
const isStatic = options.static || !options.outDir
const base = options.basename || ''
const script = base + '/bundle.js'
const hasInitialProps = typeof Component.getInitialProps === 'function'
const getProps = hasInitialProps ? Component.getInitialProps : () => ({})
const initialProps = await getProps(Object.assign({ Component }, options))
const props = Object.assign({}, options, initialProps)
const preloaded = await Loadable.preloadAll()
const body = render(Component, props, isStatic, modules)
const loadableStats = isStatic ? {} : require(path.join(__dirname, './TMP/react-loadable.json'))
const bundles = isStatic ? [] : getBundles(loadableStats, modules)
.map(bundle => bundle.file)
.map(src => base + '/' + src)
const scripts = isStatic ? [] : [
script,
...bundles
]
let css = ''
if (props.cssLibrary) {
css = getCSS(Component, props)
}
const html = createHTML({
body,
css,
initialProps: isStatic ? null : props,
scripts
})
return html
}
const writePage = async (Component, options) => {
const html = await renderHTML(Component, options)
if (options.outDir) {
const dir = path.join(
process.cwd(),
options.outDir,
options.pathname || ''
)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
const name = path.join(dir, 'index.html')
fs.writeFileSync(name, html)
}
return html
}
const createStatic = async (filename, baseOptions) => {
const Component = await getWebpackApp(filename, baseOptions)
const options = Object.assign({}, Component.defaultProps, baseOptions)
const bundle = await client(filename, options)
let html
if (options.routes && options.routes.length) {
html = await options.routes.map(async pathname => {
const res = await writePage(Component, Object.assign({}, options, {
pathname
}))
return res
})
} else {
html = await writePage(Component, options)
}
return { html, bundle }
}
module.exports = createStatic

View File

@ -1,82 +0,0 @@
const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
// bundle App for usage in node when a custom webpack config is provided
const config = {
mode: 'development',
output: {
path: path.join(__dirname, './TMP'),
filename: 'App.js',
libraryExport: 'default',
libraryTarget: 'umd',
},
target: 'node',
resolve: {
modules: []
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: require.resolve('babel-loader'),
options: {
presets: [
'babel-preset-env',
'babel-preset-stage-0',
'babel-preset-react'
].map(require.resolve),
plugins: [
require.resolve('babel-plugin-transform-runtime')
]
}
}
},
]
},
plugins: [],
}
const bundleApp = (filename, options = {}) => {
const dirname = path.dirname(filename)
config.entry = filename
config.resolve.modules.unshift(
dirname,
path.join(process.cwd(), 'node_modules'),
path.join(dirname, 'node_modules'),
)
const userConfig = require(options.config)
const mergedConfig = merge(config, userConfig)
const compiler = webpack(mergedConfig)
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
reject(err)
return
}
try {
const App = require('./TMP/App')
resolve(App)
} catch (e) {
reject(e)
}
})
})
}
const getApp = async (filename, options) => {
if (!options.config) {
const req = require(filename)
return req.default || req
}
return await bundleApp(filename, options)
}
module.exports = getApp

25
lib/template.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = ({
html = '',
css = '',
scripts,
js,
publicPath,
title = 'x0',
meta = [],
links = [],
static: staticBuild
}) =>
`<!DOCTYPE html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<meta name='generator' content='Compositor x0'>
<title>${title}</title>
${meta.map(({ name, content }) => `<meta name='${name}' content='${content}'>`).join('\n')}
${links.map(({ rel, href }) => `<link rel='${rel}' href='${href}' />`).join('\n')}
<style>*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif}</style>
${css}
</head>
<div id=root>${html}</div>
${staticBuild ? '' : scripts}
`

View File

@ -4,12 +4,12 @@
"description": "Zero-config React development environment and static site generator",
"main": "index.js",
"bin": {
"x0": "bin/cli.js"
"x0": "cli.js"
},
"scripts": {
"start": "./bin/cli.js docs/App.js -op 8888",
"static": "./bin/cli.js build docs/App.js --static -d docs",
"build": "./bin/cli.js build docs/App.js -d docs -c docs/webpack.config.js",
"start": "./cli.js docs -op 8888",
"static": "./cli.js build docs --static -d docs",
"build": "./cli.js build docs -d docs -c docs/webpack.config.js",
"test": "nyc ava",
"cover": "nyc report --reporter=html --reporter=lcov"
},
@ -17,50 +17,49 @@
"author": "Brent Jackson",
"license": "MIT",
"dependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-minify-webpack-plugin": "^0.2.0",
"babel-plugin-dynamic-import-node": "^1.2.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-core": "^6.26.3",
"babel-loader": "^7.1.4",
"babel-plugin-macros": "^2.2.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"babel-register": "^6.26.0",
"chalk": "^2.1.0",
"fela": "^6.1.0",
"fela-dom": "^7.0.0",
"glamor": "^2.20.40",
"glamorous": "^4.11.0",
"meow": "^3.7.0",
"ora": "^1.3.0",
"pkg-up": "^2.0.0",
"react": "^16.2.0",
"react-dev-utils": "^4.2.1",
"react-dom": "^16.2.0",
"react-fela": "^6.1.1",
"react-loadable": "^5.3.1",
"read-pkg-up": "^2.0.0",
"update-notifier": "^2.2.0",
"webpack": "^4.0.0",
"webpack-dev-server": "^3.0.0",
"webpack-merge": "^4.1.1"
"chalk": "^2.4.1",
"clipboardy": "^1.2.3",
"connect-history-api-fallback": "^1.5.0",
"emotion-server": "^9.1.3",
"find-up": "^2.1.0",
"fs-extra": "^6.0.1",
"html-minifier": "^3.5.15",
"koa-connect": "^2.0.1",
"meow": "^5.0.0",
"mini-html-webpack-plugin": "^0.2.3",
"pkg-conf": "^2.1.0",
"react": "^16.3.2",
"react-dev-utils": "^5.0.1",
"react-dom": "^16.3.2",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"read-pkg-up": "^3.0.0",
"semver": "^5.5.0",
"update-notifier": "^2.5.0",
"webpack": "^4.8.3",
"webpack-merge": "^4.1.2",
"webpack-serve": "^1.0.2"
},
"devDependencies": {
"ava": "^0.24.0",
"isomorphic-fetch": "^2.2.1",
"nano-style": "^1.0.0-20",
"nano-style": "^1.0.0",
"nyc": "^11.2.1",
"raw-loader": "^0.5.1",
"react-router-dom": "^4.2.2",
"refunk": "^2.0.0-1",
"styled-components": "^2.2.4",
"styled-system": "^1.0.7"
"refunk": "^2.2.4",
"styled-components": "^3.2.6",
"styled-system": "^1.1.7"
},
"x0": {
"title": "Compositor x0",
"cssLibrary": "styled-components",
"_basename": "/x0"
"basename": "/x0"
},
"ava": {
"files": [

View File

@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import test from 'ava'
import x0Static from '../lib/static'
import build from '../lib/build'
const hello = path.join(__dirname, './components/Hello.js')
const withprops = path.join(__dirname, './components/Props.js')
@ -22,7 +22,7 @@ test.before(clean)
test.after(clean)
test.cb('static renders', t => {
x0Static(hello, {})
build(hello, {})
.then(result => {
t.is(typeof result, 'object')
t.snapshot(result.html)
@ -31,7 +31,7 @@ test.cb('static renders', t => {
})
test.cb('static writes', t => {
x0Static(hello, {
build(hello, {
outDir: 'test/output'
})
.then(result => {
@ -42,7 +42,7 @@ test.cb('static writes', t => {
})
test.cb('static uses getInitialProps method', t => {
x0Static(withprops, {})
build(withprops, {})
.then(result => {
t.is(typeof result, 'object')
t.snapshot(result.html)
@ -51,7 +51,7 @@ test.cb('static uses getInitialProps method', t => {
})
test.cb('static picks up routes config', t => {
x0Static(hello, {
build(hello, {
routes: [
'/'
]
@ -68,7 +68,7 @@ test.cb('static makes a directory', t => {
fs.rmdirSync(output)
}
x0Static(hello, {
build(hello, {
outDir: 'test/output'
})
.then(result => {