├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test ├── app ├── components │ ├── App.js │ ├── Home.js │ ├── NestedPath.js │ └── NotFound.js ├── entry.js ├── nestedPaths.js └── routes.js ├── test.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | /_output 4 | /node_modules 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - node 5 | - "6" 6 | - "5" 7 | - "4" 8 | script: 9 | - npm test 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 mbaasy.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Router Path Extractor Webpack Plugin 2 | 3 | [![Build Status](https://travis-ci.org/mbaasy/react-router-path-extractor-webpack-plugin.svg?branch=master)](https://travis-ci.org/mbaasy/react-router-path-extractor-webpack-plugin) [![JavaScript Style Guide](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) [![dependencies Status](https://david-dm.org/mbaasy/react-router-path-extractor-webpack-plugin/status.svg)](https://david-dm.org/mbaasy/react-router-path-extractor-webpack-plugin) [![devDependencies Status](https://david-dm.org/mbaasy/react-router-path-extractor-webpack-plugin/dev-status.svg)](https://david-dm.org/mbaasy/react-router-path-extractor-webpack-plugin?type=dev) 4 | 5 | ## Introduction 6 | 7 | This plugin is designed to work with [StaticSiteGeneratorWebpackPlugin](https://github.com/markdalgleish/static-site-generator-webpack-plugin) and [SitemapPlugin](https://github.com/markdalgleish/static-site-generator-webpack-plugin). It has no standalone functionality other than to precompile a [React Router](https://github.com/reactjs/react-router) routes file and pass the discovered `paths` into a callback function. 8 | 9 | The callback function expects a return value consisting of an array of webpack plugins to be applied to your compiler once the paths have been resolved. 10 | 11 | ## How it works 12 | 13 | It adds a [`make`](http://webpack.github.io/docs/plugins.html#make-parallel) plugin to separately compile your routes file using the `loaders` specified in your config. 14 | 15 | Once compiled it executes the code in a [`vm`](https://nodejs.org/api/vm.html) and runs [`ReactRouter.createRoutes`](https://github.com/reactjs/react-router/blob/master/docs/API.md#createroutesroutes) on the `module.exports.routes` named export. Finally it executes the callback function, passing the flattened paths into it. 16 | 17 | The compiler respects your loaders and is indifferent to how you build your routes. i.e. you can use a combination of [``](https://github.com/reactjs/react-router/blob/master/docs/API.md#route) and [`PlainRoute`](https://github.com/reactjs/react-router/blob/master/docs/API.md#plainroute) to describe your routes. 18 | 19 | ## Signature 20 | 21 | ```javascript 22 | new ReactRouterPathExtractorWebpackPlugin( 23 | routesFile: String|{routesFile: String}, 24 | options?: {routesFile?: String}, 25 | callback: (paths: Array) => Array 26 | ) 27 | ``` 28 | 29 | ## Usage 30 | 31 | Have a look at [test suite](test) for a complete example. 32 | 33 | #### webpack.config.js 34 | ```javascript 35 | var webpack = require('webpack') 36 | var ReactRouterPathExtractorWebpackPlugin = require('react-router-path-extractor-webpack-plugin') 37 | var StaticSiteGeneratorWebpackPlugin = require('static-site-generator-webpack-plugin') 38 | var SitemapWebpackPlugin = require('sitemap-webpack-plugin') 39 | 40 | module.exports = webpack({ 41 | entry: { 42 | main: ['./src/entry.js'] 43 | }, 44 | output: { 45 | path: path.resolve(__dirname, '_dist'), 46 | filename: '[name].[hash].js', 47 | libraryTarget: 'umd', 48 | publicPath: '/' 49 | }, 50 | module: { 51 | loaders: [{ 52 | test: /\.js$/, 53 | loader: 'babel', 54 | exclude: /node_modules/, 55 | query: { 56 | presets: ['react', 'es2015'] 57 | } 58 | }] 59 | }, 60 | plugins: [ 61 | new ReactRouterPathExtractorWebpackPlugin( 62 | './src/routes.js', 63 | function (paths) { 64 | /* 65 | The callback receives a flat array of paths, e.g. 66 | [ 67 | '/', 68 | '/about', 69 | '/about/contact' 70 | ] 71 | 72 | Return an array of path dependent webpack plugins in the callback and let 73 | them do all the hard work: 74 | */ 75 | return [ 76 | new StaticSiteGeneratorWebpackPlugin('main', paths), 77 | new SitemapWebpackPlugin('http://example.com', paths) 78 | ] 79 | } 80 | ) 81 | ] 82 | }) 83 | ``` 84 | 85 | #### routes.js 86 | ```javascript 87 | import React from 'react' 88 | import { Route, IndexRoute } from 'react-router' 89 | import App from './components/App' 90 | import Home from './components/Home' 91 | import About from './components/About' 92 | import Contact from './components/Contact' 93 | import NotFound from './components/NotFound' 94 | 95 | // Important: Your routes must be a named export called "routes": 96 | // es5: module.exports.routes = ... 97 | export const routes = ( 98 | 99 | 100 | 101 | 102 | 103 | 104 | // Exclude paths by adding the staticExclude prop: 105 | 106 | // Child routes will included unless staticExclude is specified. 107 | 108 | 109 | // This catch-all will resolve to /error/index.html 110 | // staticName will default to '404', i.e. /404/index.html 111 | 112 | 113 | ) 114 | 115 | // You can always export the routes as default too for use elsewhere. 116 | export default routes 117 | ``` 118 | 119 | ## Dynamic Routing 120 | 121 | [Dynamic Routing](https://github.com/reactjs/react-router/blob/1.0.x/docs/guides/advanced/DynamicRouting.md) may be possible using a hashHistory Router within a Route component or by adding a catch-all and a bit nginx config. Example coming soon. 122 | 123 | ## Report an Issue 124 | 125 | * [Bugs](https://github.com/mbaasy/react-router-path-extractor-webpack-plugin/issues) 126 | * Contact the author: 127 | 128 | ## MIT License 129 | 130 | > Copyright (c) 2016 [Mbaasy, Inc](https://mbaasy.com/) 131 | 132 | > Permission is hereby granted, free of charge, to any person obtaining a copy 133 | of this software and associated documentation files (the "Software"), to deal 134 | in the Software without restriction, including without limitation the rights 135 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 136 | copies of the Software, and to permit persons to whom the Software is 137 | furnished to do so, subject to the following conditions: 138 | 139 | > The above copyright notice and this permission notice shall be included in all 140 | copies or substantial portions of the Software. 141 | 142 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 143 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 144 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 145 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 146 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 147 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 148 | SOFTWARE. 149 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var vm = require('vm') 4 | var MemoryFileSystem = require('memory-fs') 5 | var createRoutes = require('react-router').createRoutes 6 | var webpack = require('webpack') 7 | 8 | function compileRoutes (config) { 9 | return new Promise(function (resolve, reject) { 10 | var compiler = webpack(config) 11 | var fs = compiler.outputFileSystem = new MemoryFileSystem() 12 | compiler.run(function (err, stats) { 13 | if (err) { return reject(err) } 14 | resolve(fs) 15 | }) 16 | }) 17 | } 18 | 19 | function executeRoutes (fs) { 20 | return new Promise(function (resolve, reject) { 21 | try { 22 | var source = fs.readFileSync('/routes.js').toString() 23 | var script = new vm.Script(source, { filename: 'routes.vm' }) 24 | var context = {} 25 | var routes = script.runInNewContext(context).routes 26 | resolve(routes) 27 | } catch (err) { 28 | reject(err) 29 | } 30 | }) 31 | } 32 | 33 | function flattenRoutes (routes, base, paths) { 34 | return routes.reduce(function (paths, route) { 35 | var path = route.path === '*' ? route.staticName || 'nomatch' : route.path 36 | path = /^\//.test(path) ? path : base + path 37 | if (!route.staticExclude) { paths.push(path) } 38 | if (route.childRoutes) { 39 | var nextBase = /\/$/.test(path) ? path : path + '/' 40 | paths = flattenRoutes(route.childRoutes, nextBase, paths) 41 | } 42 | return paths 43 | }, paths || []) 44 | } 45 | 46 | function ReactRouterPathExtractorWebpackPlugin (routesFile, options, plugins) { 47 | var args = Array.prototype.splice.call(arguments, 0, 3) 48 | switch (args.length) { 49 | case 3: 50 | this.routesFile = args[0] 51 | this.options = args[1] 52 | this.plugins = args[2] 53 | break 54 | case 2: 55 | if (args[0] && typeof args[0] === 'object') { 56 | this.options = args[0] 57 | this.plugins = args[1] 58 | this.routesFile = this.options.routesFile 59 | } else { 60 | this.routesFile = args[0] 61 | this.plugins = args[1] 62 | this.options = {} 63 | } 64 | break 65 | default: 66 | throw Error('Invalid arguments') 67 | } 68 | if (!this.routesFile) { 69 | throw new Error('A routesFile is required') 70 | } 71 | if (typeof this.routesFile !== 'string') { 72 | throw new Error('The routesFile must be a string') 73 | } 74 | if (typeof this.options !== 'object') { 75 | throw new Error('Options must be an object') 76 | } 77 | if (typeof this.plugins !== 'function') { 78 | throw new Error('Callback must be a function') 79 | } 80 | } 81 | 82 | ReactRouterPathExtractorWebpackPlugin.prototype.apply = function (compiler) { 83 | var self = this 84 | compiler.plugin('make', function (compilation, callback) { 85 | compileRoutes({ 86 | context: compiler.context, 87 | entry: { 88 | routes: [self.routesFile] 89 | }, 90 | output: { 91 | path: '/', 92 | filename: 'routes.js' 93 | }, 94 | module: { 95 | loaders: compiler.options.module.loaders 96 | } 97 | }) 98 | .catch(callback) 99 | .then(executeRoutes) 100 | .catch(callback) 101 | .then(function (routes) { 102 | try { 103 | return createRoutes(routes) 104 | } catch (err) { 105 | throw err 106 | } 107 | }) 108 | .catch(callback) 109 | .then(function (routes) { 110 | var paths = flattenRoutes(routes) 111 | if (typeof self.plugins === 'function') { 112 | self.plugins(paths, routes).forEach(function (plugin) { 113 | plugin.apply(compiler) 114 | }) 115 | callback(null, routes) 116 | } 117 | }) 118 | }) 119 | } 120 | 121 | module.exports = ReactRouterPathExtractorWebpackPlugin 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-path-extractor-webpack-plugin", 3 | "version": "0.1.0", 4 | "description": "Webpack plugin to extract paths from React Router", 5 | "main": "index.js", 6 | "scripts": { 7 | "example": "webpack-dev-server --config ./test/webpack.config.js", 8 | "build:example": "webpack --config ./test/webpack.config.js", 9 | "test": "standard && mocha --compilers js:babel-register" 10 | }, 11 | "author": { 12 | "name": "Mbaasy, Inc", 13 | "email": "hello@mbaasy.com", 14 | "url": "https://mbaasy.com" 15 | }, 16 | "contributors": [ 17 | { 18 | "name": "Marc Greenstock", 19 | "email": "marc@mbaasy.com", 20 | "url": "https://github.com/marcgreenstock" 21 | } 22 | ], 23 | "license": "MIT", 24 | "repository": { 25 | "type": "git", 26 | "url": "git@github.com:mbaasy/react-router-path-extractor-webpack-plugin.git" 27 | }, 28 | "keywords": [ 29 | "webpack", 30 | "plugin", 31 | "react", 32 | "react router", 33 | "paths", 34 | "routes", 35 | "static", 36 | "html" 37 | ], 38 | "babel": { 39 | "presets": [ 40 | "react", 41 | "es2015", 42 | "stage-0" 43 | ] 44 | }, 45 | "standard": { 46 | "parser": "babel-eslint" 47 | }, 48 | "files": [ 49 | "index.js" 50 | ], 51 | "dependencies": { 52 | "memory-fs": "^0.3.0" 53 | }, 54 | "devDependencies": { 55 | "babel": "^6.5.2", 56 | "babel-core": "^6.13.2", 57 | "babel-eslint": "^6.1.2", 58 | "babel-loader": "^6.2.5", 59 | "babel-preset-es2015": "^6.13.2", 60 | "babel-preset-react": "^6.11.1", 61 | "babel-preset-stage-0": "^6.5.0", 62 | "babel-register": "^6.11.6", 63 | "chai": "^3.5.0", 64 | "mocha": "^3.0.2", 65 | "react": "^15.3.0", 66 | "react-dom": "^15.3.0", 67 | "react-router": "^2.6.1", 68 | "standard": "^7.1.2", 69 | "static-site-generator-webpack-plugin": "^2.1.0", 70 | "webpack": "^1.13.2", 71 | "webpack-dev-server": "^1.14.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/app/components/App.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, { Component, PropTypes } from 'react' 4 | import { Link } from 'react-router' 5 | 6 | class MenuItem extends Component { 7 | static propTypes = { 8 | path: PropTypes.string, 9 | staticExclude: PropTypes.bool, 10 | childRoutes: PropTypes.array, 11 | base: PropTypes.string 12 | } 13 | 14 | static defaultProps = { 15 | base: '' 16 | } 17 | 18 | render () { 19 | const { childRoutes, staticExclude } = this.props 20 | const path = /^\//.test(this.props.path) 21 | ? this.props.path 22 | : this.props.base + this.props.path 23 | const nextBase = /\/$/.test(path) ? path : path + '/' 24 | return ( 25 |
    26 |
  • 27 | {staticExclude 28 | ? {path} 29 | : {path} 30 | } 31 | {childRoutes && childRoutes.map((route) => ( 32 | 33 | ))} 34 |
  • 35 |
36 | ) 37 | } 38 | } 39 | 40 | export default class App extends Component { 41 | static propTypes = { 42 | children: PropTypes.element, 43 | routes: PropTypes.array.isRequired, 44 | location: PropTypes.object 45 | } 46 | 47 | render () { 48 | const { children, routes, location } = this.props 49 | 50 | return ( 51 |
52 | 53 |

{location.pathname}

54 | {children} 55 |
56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/app/components/Home.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, { Component } from 'react' 4 | 5 | export default class Home extends Component { 6 | render () { 7 | return ( 8 |
9 |

Welcome home

10 |
11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/app/components/NestedPath.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, { Component } from 'react' 4 | 5 | export default class NestedPath extends Component { 6 | render () { 7 | return ( 8 |
9 |

Nested Path

10 |
11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/app/components/NotFound.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, { Component } from 'react' 4 | 5 | export default class NotFound extends Component { 6 | render () { 7 | return ( 8 |
9 |

404

10 |
11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/app/entry.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import { render } from 'react-dom' 5 | import { renderToString } from 'react-dom/server' 6 | import { 7 | RouterContext, 8 | Router, 9 | browserHistory, 10 | match, 11 | createMemoryHistory 12 | } from 'react-router' 13 | import routes from './routes' 14 | 15 | const template = (html, assets) => (` 16 | 17 | 18 | 19 | ReactRouterPathExtractorWebpackPlugin Test 20 | 21 | 22 | 23 |
${html}
24 | 25 | 26 | 27 | `) 28 | 29 | if (typeof document !== 'undefined') { 30 | render( 31 | 32 | , document.getElementById('outlet')) 33 | } 34 | 35 | export default (locals, callback) => { 36 | const history = createMemoryHistory() 37 | const location = history.createLocation(locals.path) 38 | 39 | match({routes, location}, (error, redirectLocation, renderProps) => { 40 | if (error) { return callback(error) } 41 | const html = renderToString( 42 | 43 | ) 44 | callback(null, template(html, locals.assets)) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /test/app/nestedPaths.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export default [{ 4 | path: '1', 5 | childRoutes: [{ 6 | path: '1.1', 7 | staticExclude: true, 8 | childRoutes: [{ 9 | path: '1.1.1' 10 | }] 11 | }, { 12 | path: '1.2', 13 | staticExclude: true, 14 | childRoutes: [{ 15 | path: '1.2.1', 16 | childRoutes: [{ 17 | path: '1.2.1.1' 18 | }] 19 | }] 20 | }] 21 | }, { 22 | path: '2', 23 | childRoutes: [{ 24 | path: '2.1', 25 | staticExclude: true, 26 | childRoutes: [{ 27 | path: '2.1.1' 28 | }] 29 | }] 30 | }] 31 | -------------------------------------------------------------------------------- /test/app/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import { Route, IndexRoute } from 'react-router' 5 | import App from './components/App' 6 | import Home from './components/Home' 7 | import NestedPath from './components/NestedPath' 8 | import NotFound from './components/NotFound' 9 | import nestedPaths from './nestedPaths' 10 | 11 | export const routes = ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | 25 | export default routes 26 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { describe, it, before } from 'mocha' 4 | import { expect } from 'chai' 5 | import webpack from 'webpack' 6 | import MemoryFileSystem from 'memory-fs' 7 | import Plugin from '../index' 8 | import config, { data } from './webpack.config' 9 | 10 | const compiler = webpack({...config, output: {...config.output, path: '/'}}) 11 | compiler.outputFileSystem = new MemoryFileSystem() 12 | 13 | describe('ReactRouterPathExtractorWebpackPlugin', () => { 14 | describe('constructor', () => { 15 | const subject = (...args) => () => new Plugin(...args) 16 | 17 | describe('0 arguments', () => { 18 | it('should throw an error', () => { 19 | expect(subject()).to.throw(Error, 'Invalid arguments') 20 | }) 21 | }) 22 | 23 | describe('1 argument', () => { 24 | it('should throw an error', () => { 25 | expect(subject()).to.throw(Error, 'Invalid arguments') 26 | }) 27 | }) 28 | }) 29 | 30 | describe('.apply', () => { 31 | let error = null 32 | 33 | before(function (done) { 34 | this.timeout(0) 35 | compiler.run((err, stats) => { 36 | error = err 37 | done() 38 | }) 39 | }) 40 | 41 | describe('compiler', () => { 42 | it('should not error', () => { 43 | expect(error).to.be.null 44 | }) 45 | }) 46 | 47 | describe('callback', () => { 48 | it('should include the expected paths', () => { 49 | var expected = [ 50 | '/', 51 | '/nested/1', 52 | '/nested/1/1.1/1.1.1', 53 | '/nested/1/1.2/1.2.1', 54 | '/nested/1/1.2/1.2.1/1.2.1.1', 55 | '/nested/2', 56 | '/nested/2/2.1/2.1.1', 57 | '/foobar', 58 | '/foobar/whatever', 59 | '/foobar/whatever/something/something', 60 | '/foobar/whatever/nomatch', 61 | '/error' 62 | ] 63 | expect(data.paths).to.have.members(expected) 64 | }) 65 | 66 | it('should exculde the expected paths', () => { 67 | var unexpected = [ 68 | '/nested', 69 | '/nested/1/1.1', 70 | '/nested/1/1.2', 71 | '/nested/2/2.1' 72 | ] 73 | expect(data.paths).to.not.have.members(unexpected) 74 | }) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var ReactRouterPathExtractorWebpackPlugin = require('../index') 4 | var StaticSiteGeneratorWebpackPlugin = require('static-site-generator-webpack-plugin') 5 | 6 | var data = {} 7 | 8 | module.exports = { 9 | context: __dirname, 10 | entry: { 11 | main: ['./app/entry.js'] 12 | }, 13 | output: { 14 | path: './_output', 15 | filename: '[name].js', 16 | libraryTarget: 'umd' 17 | }, 18 | module: { 19 | loaders: [{ 20 | test: /\.js$/, 21 | loader: 'babel', 22 | exclude: /node_modules/, 23 | query: { 24 | presets: ['react', 'es2015', 'stage-0'] 25 | } 26 | }] 27 | }, 28 | plugins: [ 29 | new ReactRouterPathExtractorWebpackPlugin('./app/routes.js', (paths, routes) => { 30 | data.paths = paths 31 | data.routes = routes 32 | return [new StaticSiteGeneratorWebpackPlugin('main', paths)] 33 | }) 34 | ] 35 | } 36 | 37 | module.exports.data = data 38 | --------------------------------------------------------------------------------