├── .eslintignore ├── .gitignore ├── src ├── client │ ├── app │ │ ├── areas │ │ │ ├── public │ │ │ │ ├── home │ │ │ │ │ ├── styles.styl │ │ │ │ │ └── index.js │ │ │ │ ├── routes.js │ │ │ │ ├── about │ │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ │ └── errors │ │ │ │ └── 404.js │ │ ├── routes.js │ │ ├── components │ │ │ └── navigation │ │ │ │ ├── styles.styl │ │ │ │ └── index.js │ │ ├── app.js │ │ └── index.js │ └── index.js ├── server │ ├── node_modules │ │ └── common │ │ │ └── server-config.js │ ├── api │ │ └── index.js │ ├── index.js │ └── web │ │ └── index.js ├── styles │ ├── html.styl │ ├── variables.styl │ ├── index.styl │ └── normalize.css └── node_modules │ └── common │ ├── routing-helpers.js │ ├── state-driver.js │ ├── tests │ ├── utils-tests.js │ └── component-helpers-tests.js │ ├── utils.js │ └── component-helpers.js ├── mocha.opts ├── assets └── favicon.ico ├── .babelrc ├── .eslintrc ├── LICENSE ├── package.json ├── gulpfile.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /src/client/app/areas/public/home/styles.styl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-core/register 2 | --recursive 3 | ./src/**/tests.js 4 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frptools/epicycle/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /src/server/node_modules/common/server-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: 1337 3 | }; 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015" ], 3 | "plugins": [ 4 | "syntax-object-rest-spread", 5 | "transform-object-rest-spread" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/server/api/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | const server = express(); 4 | 5 | server.use((req, res, next) => { 6 | console.log(`API Request: ${req.method} ${req.url}`); 7 | next(); 8 | }); 9 | 10 | export default server; 11 | -------------------------------------------------------------------------------- /src/client/app/areas/public/routes.js: -------------------------------------------------------------------------------- 1 | import {configureRoutes} from 'common/routing-helpers'; 2 | import Home from './home'; 3 | import About from './about'; 4 | 5 | module.exports = configureRoutes([ 6 | { id: 'home', component: Home, path: '/' }, 7 | { id: 'about', component: About, path: '/about' } 8 | ]); 9 | -------------------------------------------------------------------------------- /src/client/app/areas/public/home/index.js: -------------------------------------------------------------------------------- 1 | import most from 'most'; 2 | import {p} from '@motorcycle/dom'; 3 | 4 | export default function Home(sources) { 5 | const state = { 6 | vtree: p('Home is where the heart is.'), 7 | model: { text: 'Some state information used by the home page' } 8 | }; 9 | return { 10 | state: most.just(state) 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/styles/html.styl: -------------------------------------------------------------------------------- 1 | html 2 | border-top 10px solid $color1 3 | 4 | body 5 | margin 40px 6 | font-family $paragraph-font-family 7 | font-size $paragraph-font-size 8 | color $paragraph-color 9 | 10 | a 11 | color $link-color 12 | text-decoration none 13 | &:hover 14 | color $link-hover-color 15 | 16 | h1 17 | font-size $h1-font-size 18 | font-weight 400 19 | letter-spacing -0.03em 20 | color $h1-color 21 | -------------------------------------------------------------------------------- /src/client/app/routes.js: -------------------------------------------------------------------------------- 1 | import {configureRoutes} from 'common/routing-helpers'; 2 | import Home from './areas/public/home'; 3 | import About from './areas/public/about'; 4 | import NotFound from './areas/errors/404'; 5 | 6 | module.exports = configureRoutes([ 7 | { id: 'home', component: Home, path: '/' }, 8 | { id: 'about', component: About, path: '/about' }, 9 | { id: '404', component: NotFound, path: '*' } 10 | ]); 11 | -------------------------------------------------------------------------------- /src/styles/variables.styl: -------------------------------------------------------------------------------- 1 | $color1 = #dc3522 2 | $color2 = #d9cb9e 3 | $color3 = #374140 4 | $color4 = #2a2c2b 5 | $color5 = #1e1e20 6 | $color6 = #006478 7 | // $color6 = #8f190b 8 | 9 | $heading-font-face = 'PT Sans', sans-serif 10 | $h1-font-size = 36px 11 | $h1-color = $color3 12 | 13 | $paragraph-font-family = 'Roboto', sans-serif 14 | $paragraph-font-size = 15px 15 | $paragraph-color = $color5 16 | 17 | $link-color = $color3 18 | $link-hover-color = $color1 19 | -------------------------------------------------------------------------------- /src/client/app/areas/errors/404.js: -------------------------------------------------------------------------------- 1 | import {p} from '@motorcycle/dom'; 2 | import {makePageComponent} from 'common/component-helpers'; 3 | 4 | function makeView(state) { 5 | return ( 6 | p('Page not found.') 7 | ); 8 | } 9 | 10 | function makeModel(state) { 11 | return { 12 | title: 'Page Not Found', 13 | status: 'notfound' 14 | }; 15 | } 16 | 17 | export default makePageComponent({ 18 | makeView, 19 | makeModel 20 | }); 21 | -------------------------------------------------------------------------------- /src/client/app/areas/public/about/index.js: -------------------------------------------------------------------------------- 1 | import {p} from '@motorcycle/dom'; 2 | import {makePageComponent} from 'common/component-helpers'; 3 | 4 | function makeView(state) { 5 | return ( 6 | p('All about us.') 7 | ); 8 | } 9 | 10 | function makeModel(state) { 11 | return { 12 | title: 'About Us', 13 | foo: 'The "about" page made this data' 14 | }; 15 | } 16 | 17 | export default makePageComponent({ 18 | makeView, 19 | makeModel 20 | }); 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "es6": true, 7 | "mocha": true 8 | }, 9 | "rules": { 10 | "semi": [2, "always"], 11 | "new-cap": 0, 12 | "strict": 0, 13 | "no-underscore-dangle": 0, 14 | "no-unused-vars": [1, {"vars": "all", "args": "none"}], 15 | "eol-last": 0, 16 | "no-undef": 2, 17 | "quotes": [2, "single", "avoid-escape"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import {run} from '@motorcycle/core'; 2 | import {makeDOMDriver} from '@motorcycle/dom'; 3 | import {makeRouterDriver} from '@motorcycle/router'; 4 | import {createHistory} from 'history'; 5 | import {makeBrowserStateDriver} from 'common/state-driver'; 6 | 7 | import main from './app'; 8 | 9 | run(main, { 10 | state: makeBrowserStateDriver(makeDOMDriver('.app-root')), 11 | router: makeRouterDriver(createHistory(), { capture: true }) 12 | }); 13 | -------------------------------------------------------------------------------- /src/styles/index.styl: -------------------------------------------------------------------------------- 1 | /* 2 | To keep styles bundled with components, save the styles alongside the component's JavaScript files and use the 3 | convention 'styles.styl' OR 'styles/index.styl'. The imports below will first import shared globals, then target 4 | the file/folder name 'styles' as the standard entrypoint for styles scoped to a particular page or component. 5 | */ 6 | 7 | @require 'variables' 8 | @require 'normalize.css' 9 | @require 'html' 10 | @require '../client/app/**/styles' 11 | -------------------------------------------------------------------------------- /src/client/app/areas/public/index.js: -------------------------------------------------------------------------------- 1 | import {div} from '@motorcycle/dom'; 2 | import {makePageComponent} from 'common/component-helpers'; 3 | import {routes} from './routes'; 4 | 5 | function makeView(state) { 6 | return ( 7 | div(`.area.area--${state.model.area}`, [ 8 | state.page.vtree 9 | ]) 10 | ); 11 | } 12 | 13 | function makeModel({ page: { model } }) { 14 | return { 15 | area: 'public' 16 | }; 17 | } 18 | 19 | export default makePageComponent({ 20 | routes, 21 | makeView, 22 | makeModel 23 | }); 24 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import webServer from './web'; 3 | import apiServer from './api'; 4 | import config from 'common/server-config'; 5 | import {logError} from 'common/utils'; 6 | 7 | require('source-map-support').install(); 8 | 9 | var server = express(); 10 | server.use('/api', apiServer); 11 | server.use(webServer); 12 | 13 | const port = process.env.port||config.port; 14 | server.listen(port, () => { 15 | console.log(`Now listening on port ${port}`); 16 | }); 17 | 18 | process.on('uncaughtException', logError); 19 | -------------------------------------------------------------------------------- /src/client/app/components/navigation/styles.styl: -------------------------------------------------------------------------------- 1 | .nav-primary 2 | margin-bottom 30px 3 | &__links 4 | display flex 5 | margin 0 6 | padding 0 7 | &__link 8 | list-style-type none 9 | + .nav-primary__link:not(.nav-primary__link--active) 10 | margin-left 20px 11 | + .nav-primary__link.nav-primary__link--active 12 | margin-left 10px 13 | a 14 | display block 15 | color $color4 16 | border-bottom 2px solid #e7e7e7 17 | padding-bottom 5px 18 | &:hover 19 | color $link-hover-color 20 | border-bottom-color $link-hover-color 21 | &--active 22 | padding 10px 23 | margin -10px 24 | background-color #f7f7f7 25 | a 26 | border-bottom-color $link-hover-color 27 | -------------------------------------------------------------------------------- /src/node_modules/common/routing-helpers.js: -------------------------------------------------------------------------------- 1 | export function configureRoutes(routeDefinitions) { 2 | const routes = routeDefinitions.reduce((acc, r) => Object.assign(acc, { [r.path]: r.component }), {}); 3 | const paths = routeDefinitions.reduce((acc, r) => Object.assign(acc, { [r.id]: r.path }), {}); 4 | return { 5 | routes, // to be passed to the router 6 | paths // dictionary of route id to route path (for generating hrefs) 7 | }; 8 | } 9 | 10 | export function applyRouter(sources, routes) { 11 | const {router} = sources; 12 | const {match$: route$} = router.define(routes); 13 | const sinks$ = route$.map(route => { 14 | const sinks = route.value({...sources, router: router.path(route.path)}); 15 | return { route, sinks }; 16 | }); 17 | return sinks$; 18 | } 19 | -------------------------------------------------------------------------------- /src/client/app/app.js: -------------------------------------------------------------------------------- 1 | import {h1, div} from '@motorcycle/dom'; 2 | import {makePageComponent} from 'common/component-helpers'; 3 | import {routes, paths} from './routes'; 4 | import Navigation from './components/navigation'; 5 | 6 | function makeView({ nav, page, model }) { 7 | return ( 8 | div('.app', [ 9 | div('.app__nav', [nav.vtree]), 10 | h1(model.heading), 11 | div('.app__content', [page.vtree]) 12 | ]) 13 | ); 14 | } 15 | 16 | function makeModel({ page: { model } }) { 17 | const appTitle = 'Epicycle: Isomorphic Foundation'; 18 | const title = model && model.title ? `${appTitle} / ${model.title}` : appTitle; 19 | const status = model.status; 20 | const heading = model.heading || model.title || appTitle; 21 | return { 22 | title, 23 | status, 24 | heading // propagate these automatically if not manually specified 25 | }; 26 | } 27 | 28 | export default makePageComponent({ 29 | routes, 30 | makeView, 31 | makeModel, 32 | main: sources => ({ 33 | nav: Navigation(sources, paths), 34 | }) 35 | }); 36 | -------------------------------------------------------------------------------- /src/client/app/components/navigation/index.js: -------------------------------------------------------------------------------- 1 | import {nav, ul, li, a} from '@motorcycle/dom'; 2 | 3 | function view(route, paths, ahref) { 4 | return ( 5 | nav('.nav-primary', [ 6 | ul('.nav-primary__links', [ 7 | li('.nav-primary__link', { class: { 'nav-primary__link--active': route.fullPath === paths.home } }, [ahref(paths.home, 'Home')]), 8 | li('.nav-primary__link', { class: { 'nav-primary__link--active': route.fullPath === paths.about } }, [ahref(paths.about, 'About')]), 9 | li('.nav-primary__link', { class: { 'nav-primary__link--active': false } }, [ahref('/broken-link-should-generate-404', 'This link is broken')]) 10 | ]) 11 | ]) 12 | ); 13 | } 14 | 15 | export default function Navigation(sources, paths) { 16 | const ahref = (url, text) => a({attrs: {href: sources.router.createHref(url)}}, text); 17 | const route$ = sources.page$.map(page => page.state).switch().map(state => state.route); 18 | const view$ = route$.map(route => view(route, paths, ahref)); 19 | return { 20 | DOM: view$ 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Nathan Ridley 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 | -------------------------------------------------------------------------------- /src/client/app/index.js: -------------------------------------------------------------------------------- 1 | import {assign} from 'common/utils'; 2 | import App from './app'; 3 | 4 | function sanitizeStatus(state) { 5 | const model = state.model || {}; 6 | if(model.status && typeof model.status === 'object') { 7 | return state; 8 | } 9 | const newModel = assign(model, { status: { type: model.status || 'found' }}); 10 | return assign(state, { model: newModel }); 11 | } 12 | 13 | export default function main(sources) { 14 | // applyAPI must be called first in order to surface its internals for general consumption 15 | const convertedSources = sources.state.applyAPI(sources); 16 | const sinks = App(convertedSources); 17 | 18 | if(sinks.DOM) { 19 | console.warning('App should return a state sink, rather than a DOM sink'); 20 | const state$ = sinks.DOM.map(vtree => ({ vtree })); 21 | sinks.state = (sinks.state ? sinks.state.merge(state$) : state$); 22 | delete sinks.DOM; 23 | } 24 | else if(!sinks.state) { 25 | throw new Error('App component failed to return a state sink'); 26 | } 27 | 28 | // specifying a string value for `status` is just an upstream convenience 29 | // for when additional context values are not required (as opposed to cases 30 | // like 302 redirects, which also require a location value). 31 | sinks.state = sinks.state.map(sanitizeStatus); 32 | // sinks.state = sinks.state.map(debug(sanitizeStatus, 'STATE')); 33 | 34 | return sinks; 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motorcycle-isomorphic-boilerplate", 3 | "version": "0.1.0", 4 | "description": "Isomorphic Boilerplate for Motorcycle.js", 5 | "repository": "https://github.com/axefrog/motorcycle-isomorphic-boilerplate", 6 | "author": "Nathan Ridley", 7 | "license": "MIT", 8 | "main": "dist/app/server/index.js", 9 | "scripts": { 10 | "test": "mocha --opts ./mocha.opts" 11 | }, 12 | "dependencies": { 13 | "@cycle/isolate": "^1.2.0", 14 | "@most/hold": "^1.1.0", 15 | "@motorcycle/core": "^1.1.0", 16 | "@motorcycle/dom": "^1.2.0", 17 | "@motorcycle/history": "^2.1.0", 18 | "@motorcycle/html": "^1.1.0", 19 | "@motorcycle/http": "^2.2.0", 20 | "@motorcycle/router": "^1.2.0", 21 | "express": "^4.13.4", 22 | "history": "^2.0.0", 23 | "most": "^0.18.1", 24 | "source-map-support": "^0.4.0" 25 | }, 26 | "devDependencies": { 27 | "autoprefixer": "^6.3.3", 28 | "babel-eslint": "^5.0.0", 29 | "babel-plugin-transform-object-rest-spread": "^6.5.0", 30 | "babel-preset-es2015": "^6.5.0", 31 | "babel-register": "^6.5.2", 32 | "babelify": "^7.2.0", 33 | "browser-sync": "^2.11.1", 34 | "browserify": "^13.0.0", 35 | "chai": "^3.5.0", 36 | "eslint": "^2.2.0", 37 | "gulp": "^3.9.1", 38 | "gulp-babel": "^6.1.2", 39 | "gulp-concat": "^2.6.0", 40 | "gulp-cssmin": "^0.1.7", 41 | "gulp-eslint": "^2.0.0", 42 | "gulp-mocha": "^2.2.0", 43 | "gulp-plumber": "^1.1.0", 44 | "gulp-postcss": "^6.1.0", 45 | "gulp-sourcemaps": "^1.6.0", 46 | "gulp-stylus": "^2.3.0", 47 | "gulp-uglify": "^1.5.2", 48 | "gulp-util": "^3.0.7", 49 | "rimraf": "^2.5.2", 50 | "run-sequence": "^1.1.5", 51 | "vinyl-buffer": "^1.0.0", 52 | "vinyl-source-stream": "^1.1.0", 53 | "watchify": "^3.7.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/node_modules/common/state-driver.js: -------------------------------------------------------------------------------- 1 | import {assign, logError} from './utils'; 2 | import {collapseStateToModel} from './component-helpers'; 3 | 4 | function makeStateFactory(makeView, makeModel) { 5 | return function makeState(state) { 6 | const components = collapseStateToModel(state); 7 | const newState = {}; 8 | if(makeModel) { 9 | const model = makeModel(state); 10 | if(makeView) { 11 | newState.vtree = makeView(assign(state, { model })); 12 | } 13 | newState.model = assign(model, { components }); 14 | } 15 | else { 16 | if(makeView) { 17 | newState.vtree = makeView(state); 18 | } 19 | newState.model = { components }; 20 | } 21 | return newState; 22 | }; 23 | } 24 | 25 | // adapted from https://github.com/motorcyclejs/dom/blob/develop/src/isolate.js 26 | const SCOPE_PREFIX = 'cycle-scope-'; 27 | function isolateSink(sink, scope) { 28 | return sink.map(state => { 29 | const vtree = state.vtree; 30 | if(vtree && vtree.sel.indexOf(`${SCOPE_PREFIX}${scope}`) === -1) { 31 | if (vtree.data.ns) { // svg elements 32 | const {attrs = {}} = vtree.data; 33 | attrs.class = `${attrs.class || ''} ${SCOPE_PREFIX}${scope}`; 34 | } 35 | else { 36 | vtree.sel = `${vtree.sel}.${SCOPE_PREFIX}${scope}`; 37 | } 38 | } 39 | return state; 40 | }); 41 | } 42 | 43 | export function makeStateDriver(domDriver) { 44 | return function stateDriver(state$) { 45 | state$ = state$.multicast(); 46 | const vtree$ = state$ 47 | .filter(p => p.vtree) 48 | .map(p => p.vtree); 49 | 50 | const DOM = domDriver(vtree$); 51 | const source = { 52 | state$, 53 | DOM, 54 | applyAPI: sources => assign(sources, { 55 | DOM, 56 | isolateSink, 57 | state: { 58 | makeFactory: makeStateFactory 59 | } 60 | }) 61 | }; 62 | return source; 63 | }; 64 | } 65 | 66 | export function makeBrowserStateDriver(domDriver) { 67 | function setTitle(title) { 68 | document.title = title; 69 | } 70 | 71 | return function(state$) { 72 | state$ = state$.multicast(); 73 | const stateDriver = makeStateDriver(domDriver); 74 | const source = stateDriver(state$); 75 | 76 | source.state$ 77 | .filter(p => p.model && (p.model.title || p.model.heading)) 78 | .map(p => p.model.title || p.model.heading) 79 | .observe(setTitle) 80 | .catch(logError); 81 | 82 | return source; 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /src/server/web/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | import hold from '@most/hold'; 4 | import {run} from '@motorcycle/core'; 5 | import htmlDriver from '@motorcycle/html'; 6 | import {createServerHistory, createLocation} from '@motorcycle/history'; 7 | import {html, head, title, link, body, div, script} from '@motorcycle/dom'; 8 | import {makeRouterDriver} from '@motorcycle/router'; 9 | import {makeStateDriver} from 'common/state-driver'; 10 | import {assign, logError} from 'common/utils'; 11 | 12 | import App from '../../client/app'; 13 | 14 | function makeDocumentView({ vtree, model }) { 15 | const titleEl = []; 16 | if(model.title) { 17 | titleEl.push(title(model.title)); 18 | } 19 | return ( 20 | html([ 21 | head([ 22 | ...titleEl, 23 | link({attrs: {rel: 'stylesheet', type: 'text/css', href:'https://fonts.googleapis.com/css?family=PT+Sans:400,700|Roboto:400,700,400italic,700italic'}}), 24 | link({attrs: {rel: 'stylesheet', type: 'text/css', href:'css/styles.css'}}) 25 | ]), 26 | body([ 27 | div('.app-root', [vtree]), 28 | script({ props: {src: '/js/client.js' }}) 29 | ]) 30 | ]) 31 | ); 32 | } 33 | 34 | function makeServerMainFn(main) { 35 | return function(sources) { 36 | const sinks = main(sources); 37 | const state = sinks.state.map(state => { 38 | const newState = assign(state, { vtree: makeDocumentView(state) }); 39 | return newState; 40 | }); 41 | return assign(sinks, { state }); 42 | }; 43 | } 44 | 45 | function generateResponse(req, callback) { 46 | const mainFn = makeServerMainFn(App); 47 | const history = createServerHistory(); 48 | const {sources} = run(mainFn, { 49 | state: makeStateDriver(htmlDriver), 50 | router: makeRouterDriver(history) 51 | }); 52 | const html$ = hold(sources.state.DOM 53 | .select(':root').observable 54 | .map(html => `${html}`)); 55 | const state$ = hold(sources.state.state$); 56 | html$ 57 | .zip((html, state) => ({ html, state }), state$) 58 | .take(1) 59 | .observe(callback) 60 | .catch(logError); 61 | history.push(createLocation({ pathname: req.url })); 62 | } 63 | 64 | // ---------------------------------------------------------------------------- 65 | 66 | const server = express(); 67 | 68 | server.use((req, res, next) => { 69 | console.log(`WEB Request: ${req.method} ${req.url}`); 70 | next(); 71 | }); 72 | 73 | server.use(express.static(path.resolve(__dirname + '/../../../www'))); 74 | 75 | server.use(function (req, res) { 76 | // ignore favicon requests (should have been served before now via express.static) 77 | if(req.url === '/favicon.ico') { 78 | res.writeHead(200, {'Content-Type': 'image/x-icon'}); 79 | res.end(); 80 | return; 81 | } 82 | 83 | generateResponse(req, ({ html, state: { model: { status } } }) => { 84 | let statusCode; 85 | switch(status.type) { 86 | case 'notfound': 87 | statusCode = 404; 88 | break; 89 | case 'moved': 90 | return res.redirect(301, status.location); 91 | case 'redirect': 92 | return res.redirect(302, status.location); 93 | default: 94 | statusCode = 200; 95 | break; 96 | } 97 | res.status(statusCode).send(html); 98 | }); 99 | }); 100 | 101 | export default server; 102 | -------------------------------------------------------------------------------- /src/node_modules/common/tests/utils-tests.js: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import {prune} from '../utils'; 3 | 4 | describe('utils', () => { 5 | describe('#prune()', () => { 6 | const now = new Date(); 7 | function avoid(x, y) { 8 | this.x = x; 9 | this.y = y; 10 | this.obj = { 11 | foo: 'bar' 12 | }; 13 | } 14 | const example = { 15 | now, 16 | num: 123, 17 | x: 3, 18 | foo: { 19 | x: 50, 20 | y: 100 21 | }, 22 | obj: { 23 | now, 24 | foo: { 25 | bar: 'foobar', 26 | obj: new avoid(456, 789) 27 | }, 28 | x: 5, 29 | y: 8, 30 | test: { 31 | abc: 'xyz', 32 | x: 7, 33 | now 34 | }, 35 | obj: { 36 | bar: 'barfoo' 37 | } 38 | } 39 | }; 40 | 41 | it('should return the original value if not a plain object', () => { 42 | const result1 = prune(['now', 'x'], 5); 43 | const result2 = prune(['now', 'x'], new avoid(456, 789)); 44 | assert.strictEqual(result1, 5); 45 | assert.deepEqual(result2, new avoid(456, 789)); 46 | }); 47 | 48 | it('should omit the specified properties if an array is supplied for the first argument', () => { 49 | const result = prune(['now', 'x'], example); 50 | assert.deepEqual(result, { 51 | num: 123, 52 | foo: { 53 | y: 100 54 | }, 55 | obj: { 56 | foo: { 57 | bar: 'foobar', 58 | obj: new avoid(456, 789) 59 | }, 60 | y: 8, 61 | test: { 62 | abc: 'xyz' 63 | }, 64 | obj: { 65 | bar: 'barfoo' 66 | } 67 | } 68 | }); 69 | }); 70 | 71 | it('should whitelist the specified properties if the `invert` option == true', () => { 72 | const result = prune({ 73 | properties: ['now', 'x', 'obj'], 74 | invert: true 75 | }, example); 76 | assert.deepEqual(result, { 77 | now, 78 | x: 3, 79 | obj: { 80 | now, 81 | x: 5, 82 | obj: {} 83 | } 84 | }); 85 | }); 86 | 87 | it('should only traverse deep properties contained within plain objects', () => { 88 | const result = prune({ 89 | properties: ['obj', 'foo'], 90 | invert: true 91 | }, example); 92 | assert.deepEqual(result, { 93 | foo: {}, 94 | obj: { 95 | foo: { 96 | obj: new avoid(456, 789) 97 | }, 98 | obj: {} 99 | } 100 | }); 101 | }); 102 | 103 | it('should replace specified properties if a `replaceWith` option is provided', () => { 104 | const token = '#pruned'; 105 | const result = prune({ 106 | properties: ['x'], 107 | replaceWith: token 108 | }, example); 109 | assert.deepEqual(result, { 110 | now, 111 | num: 123, 112 | x: token, 113 | foo: { 114 | x: token, 115 | y: 100 116 | }, 117 | obj: { 118 | now, 119 | foo: { 120 | bar: 'foobar', 121 | obj: new avoid(456, 789) 122 | }, 123 | x: token, 124 | y: 8, 125 | test: { 126 | abc: 'xyz', 127 | x: token, 128 | now 129 | }, 130 | obj: { 131 | bar: 'barfoo' 132 | } 133 | } 134 | }); 135 | }); 136 | 137 | it('should replace other properties if a `replaceWith` option is provided and `invert` == true', () => { 138 | const token = '#pruned'; 139 | const result = prune({ 140 | properties: ['x', 'obj'], 141 | invert: true, 142 | replaceWith: token 143 | }, example); 144 | assert.deepEqual(result, { 145 | now: token, 146 | num: token, 147 | x: 3, 148 | foo: token, 149 | obj: { 150 | now: token, 151 | foo: token, 152 | x: 5, 153 | y: token, 154 | test: token, 155 | obj: { 156 | bar: token 157 | } 158 | } 159 | }); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/node_modules/common/utils.js: -------------------------------------------------------------------------------- 1 | import util from 'util'; 2 | 3 | export function inspect(...args) { 4 | if(process.browser) 5 | console.log(...args); 6 | else { 7 | console.log(...args.map(arg => util.inspect(arg, false, 10, true))); 8 | } 9 | } 10 | 11 | export function debug(...args) { 12 | const defaultArgs = args.slice(1); 13 | if(typeof args[0] !== 'function') { 14 | throw new Error('debug() must take a function as the first argument, followed by zero or more optional parameters to be logged to the console.'); 15 | } 16 | return (...moreArgs) => { 17 | inspect(...defaultArgs, '[INPUTS]', ...moreArgs); 18 | const value = args[0](...moreArgs); 19 | inspect(...defaultArgs, '[OUTPUT]', value); 20 | return value; 21 | }; 22 | } 23 | 24 | export function logError(e) { 25 | console.error('ERROR:', e.message || e); 26 | if(e.stack) { 27 | console.error(e.stack); 28 | } 29 | } 30 | 31 | export function assign() { 32 | return Object.assign({}, ...arguments); 33 | } 34 | 35 | export function isPlainObject(obj) { 36 | return obj && typeof obj === 'object' && obj.constructor === Object.prototype.constructor; 37 | } 38 | 39 | export function identity(x) { 40 | return x; 41 | } 42 | 43 | export function bind(fn, ...args) { 44 | return fn.bind(null, ...args); 45 | } 46 | 47 | export function curry(f) { 48 | // credit to: https://gist.github.com/djtriptych/7260910a5b32a572cfad 49 | return function () { 50 | var args = Array.prototype.slice.call(arguments, 0); 51 | return args.length < f.length 52 | ? curry(args.reduce(function(g, arg) { return g.bind(null, arg); }, f)) 53 | : f.apply(null, args); 54 | }; 55 | } 56 | 57 | export function pick(properties, obj) { 58 | const value = {}; 59 | for(let key of properties) { 60 | if(key in obj) { 61 | value[key] = obj[key]; 62 | } 63 | } 64 | return value; 65 | } 66 | 67 | export function omit(properties, obj) { 68 | const value = {}; 69 | const set = new Set(properties); 70 | for(let key in obj) { 71 | if(!set.has(key)) { 72 | value[key] = obj[key]; 73 | } 74 | } 75 | return value; 76 | } 77 | 78 | export function prune(options, obj) { 79 | if(!isPlainObject(obj)) { 80 | return obj; 81 | } 82 | if(options instanceof Array) { 83 | options = { properties: options }; 84 | } 85 | const invert = !!options.invert; 86 | const replaceWith = 'replaceWith' in options ? options.replaceWith : void 0; 87 | const replacementExists = replaceWith !== void 0; 88 | const set = new Set(options.properties); 89 | let it; 90 | const stack = [[obj, {}, (it = Object.keys(obj).entries()), it.next()]]; 91 | while(true) { 92 | const layer = stack[stack.length - 1]; 93 | let [src, dest, it, current] = layer; 94 | if(current.done) { 95 | stack.pop(); 96 | if(stack.length === 0) { 97 | return dest; 98 | } 99 | continue; 100 | } 101 | let key = current.value[1]; 102 | layer[3] = it.next(); 103 | const shouldPrune = set.has(key) !== invert; 104 | if(shouldPrune) { 105 | if(replacementExists) { 106 | dest[key] = replaceWith; 107 | } 108 | } 109 | else { 110 | const value = src[key]; 111 | if(isPlainObject(value)) { 112 | const newValue = dest[key] = {}; 113 | dest = newValue; 114 | stack.push([value, dest, (it = Object.keys(value).entries()), it.next()]); 115 | continue; 116 | } 117 | else { 118 | dest[key] = value; 119 | } 120 | } 121 | } 122 | } 123 | 124 | // temporary; for diagnostic purposes only. 125 | export const lookat = (...args) => inspect(...args.slice(0, args.length-1), prune({ properties: ['vtree'], replaceWith: '?' }, args[args.length-1])); 126 | 127 | function merge2(a, b) { 128 | for(let key in b) { 129 | const source = b[key]; 130 | const target = a[key]; 131 | if(isPlainObject(target) && isPlainObject(source)) { 132 | a[key] = merge2(target, source); 133 | } 134 | else if(source === void 0) { 135 | delete a[key]; 136 | } 137 | else { 138 | a[key] = b[key]; 139 | } 140 | } 141 | return a; 142 | } 143 | 144 | /** Merges the properties of each argument into the one before it. Deep merging 145 | * is applied only where both the source and target properties are plain 146 | * JavaScript objects (constructor must be Object). Arrays are not merged. 147 | */ 148 | export function deepMerge() { 149 | if(arguments.length <= 1) { 150 | return arguments[0]; 151 | } 152 | let source = arguments[arguments.length - 1]; 153 | for(let i = arguments.length - 2; i >= 0; i--) { 154 | let target = arguments[i]; 155 | source = merge2(target, source); 156 | } 157 | return source; 158 | } 159 | -------------------------------------------------------------------------------- /src/node_modules/common/component-helpers.js: -------------------------------------------------------------------------------- 1 | import most from 'most'; 2 | import isolate from '@cycle/isolate'; 3 | import {assign, isPlainObject, identity} from './utils'; 4 | import {applyRouter} from './routing-helpers'; 5 | 6 | export function partitionComponents(components) { 7 | return Array.from(Object.keys(components)) 8 | .reduce((acc, key) => 9 | (((components[key] instanceof most.Stream && acc.streams.push({ key, sinks$: components[key] })) || 10 | (isPlainObject(components[key]) && acc.statics.push({ key, sinks: components[key] }))), acc), 11 | { streams:[], statics:[] }); 12 | } 13 | 14 | function projectSinkFromComponentStream(key, sinks$) { 15 | return sinks$ 16 | .filter(sinks => sinks[key] && sinks[key] instanceof most.Stream) 17 | .map(sinks => sinks[key]) 18 | .switch(); 19 | } 20 | 21 | /** Merges the specified sinks from multiple components into a single set of output sinks */ 22 | export function mergeSinks(sinkKeys, { streams, statics }) { 23 | return sinkKeys.reduce((sinks, key) => { 24 | const a = streams.map(component => projectSinkFromComponentStream(key, component.sinks$)); 25 | const b = statics.map(component => component.sinks[key]).filter(identity); 26 | sinks[key] = most.mergeArray(a.concat(b)); 27 | return sinks; 28 | }, {}); 29 | } 30 | 31 | function stateFromSinks(sinks) { 32 | const state$ = sinks.DOM 33 | ? sinks.state 34 | ? sinks.state.combine((state, vtree) => assign(state, { vtree }), sinks.DOM) 35 | : sinks.DOM.map(vtree => ({ vtree })) 36 | : sinks.state || most.just({}); 37 | return state$; 38 | } 39 | 40 | function buildPairStreamArray(statics) { 41 | /* 42 | @param statics: [{ key, sinks }, ...] 43 | where sinks: { state, ... } // state is a stream of plain state objects; '...' indicates other discarded sinks 44 | @returns: [pair$, ...] 45 | where pair: { key, state } 46 | */ 47 | return statics.map(({ key, sinks }) => stateFromSinks(sinks).map(state => ({ key, state }))); 48 | } 49 | 50 | function buildHigherOrderPairStreamArray(streams) { 51 | /* 52 | @param streams: [{ key, sinks$ }, ...] 53 | @returns: [pair$$, ...] 54 | where pair: { key, state } 55 | */ 56 | const makePair = (key, state) => ({ key, state }); 57 | const pair$FromSinks = (key, sinks) => stateFromSinks(sinks).map(state => makePair(key, state)); 58 | const pair$$FromBasePair = ({ key, sinks$ }) => sinks$.map(sinks => pair$FromSinks(key, sinks)); 59 | const pair$$arr = streams.map(pair$$FromBasePair); 60 | return pair$$arr; 61 | } 62 | 63 | function makeChannelPerSinkKey(pair$$arr, pair$arr) { 64 | return pair$$arr.map(pair$$ => pair$$.switch()).concat(pair$arr); 65 | } 66 | 67 | function buildStateStreamFromChannels(channels) { 68 | /* 69 | @param channels: [pair$, ...] 70 | @returns stream: | state, state, ..., --> 71 | */ 72 | return most 73 | .combineArray((...pairs) => pairs, channels) 74 | .map(pairs => pairs.sort((a,b) => a.key.localeCompare(b.key)) 75 | .reduce((acc, pair) => ((acc[pair.key] = pair.state), acc), {})); 76 | } 77 | 78 | export function buildStateStream({ streams, statics }) { 79 | const pair$arr = buildPairStreamArray(statics); 80 | const pair$$arr = buildHigherOrderPairStreamArray(streams); 81 | const channels = makeChannelPerSinkKey(pair$$arr, pair$arr); 82 | return buildStateStreamFromChannels(channels); 83 | } 84 | 85 | export var internals = { 86 | stateFromSinks, 87 | projectSinkFromComponentStream, 88 | buildPairStreamArray, 89 | buildHigherOrderPairStreamArray, 90 | makeChannelPerSinkKey, 91 | buildStateStreamFromChannels 92 | }; 93 | 94 | export function consolidateChildren(components, keysToMerge, fnCreateRepresentation) { 95 | // Pending requests from children (such as http calls, etc.) need to be passed to output sinks. 96 | // Some children will be streams of component sinks, whereas others will be static sets of sinks. 97 | const partitioned = partitionComponents(components); 98 | const sinks = keysToMerge ? mergeSinks(keysToMerge, partitioned) : {}; 99 | const state$ = buildStateStream(partitioned); 100 | return { sinks, state$ }; 101 | } 102 | 103 | export function collapseStateToModel(state) { 104 | const newState = {}; 105 | for(let key in state) { 106 | const value = state[key]; 107 | if(!isPlainObject(value)) { 108 | continue; 109 | } 110 | if('model' in value) { 111 | newState[key] = value.model; 112 | } 113 | } 114 | return newState; 115 | } 116 | 117 | function makePageSink(routeApplied$) { 118 | return routeApplied$ 119 | .map(r => assign(r.sinks, { 120 | state: r.sinks.state.map(state => assign(state, { route: r.route })) 121 | })); 122 | } 123 | 124 | function makeNewPageState(makeState, state) { 125 | const newState = makeState(state); 126 | return newState; 127 | } 128 | 129 | export function makePageComponent({ routes, makeView, makeModel, main }) { 130 | return isolate(function PageComponent(sources) { 131 | const makeState = sources.state.makeFactory(makeView, makeModel); 132 | let components; 133 | if(routes) { 134 | const routeApplied$ = applyRouter(sources, routes); // => stream of { route, sinks } 135 | const page$ = makePageSink(routeApplied$); 136 | components = assign({ page: page$ }, main ? main(assign(sources, { page$ })) : {}); 137 | } 138 | else if(main) { 139 | components = main(sources); 140 | } 141 | if(components) { 142 | const merged = consolidateChildren(components); 143 | const state$ = merged.state$.map(state => makeNewPageState(makeState, state)); 144 | return assign(merged.sinks, { state: state$ }); 145 | } 146 | return { state: most.just(makeNewPageState(makeState, {})) }; 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /* 2 | * What follows is the result of much research on cross-browser styling. 3 | * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, 4 | * Kroc Camen, and the H5BP dev community and team. 5 | */ 6 | 7 | /* ========================================================================== 8 | Base styles: opinionated defaults 9 | ========================================================================== */ 10 | 11 | html { 12 | color: #222; 13 | font-size: 1em; 14 | line-height: 1.4; 15 | } 16 | 17 | /* 18 | * Remove text-shadow in selection highlight: 19 | * https://twitter.com/miketaylr/status/12228805301 20 | * 21 | * These selection rule sets have to be separate. 22 | * Customize the background color to match your design. 23 | */ 24 | 25 | ::selection { 26 | background: #b3d4fc; 27 | text-shadow: none; 28 | } 29 | 30 | /* 31 | * A better looking default horizontal rule 32 | */ 33 | 34 | hr { 35 | display: block; 36 | height: 1px; 37 | border: 0; 38 | border-top: 1px solid #ccc; 39 | margin: 1em 0; 40 | padding: 0; 41 | } 42 | 43 | /* 44 | * Remove the gap between audio, canvas, iframes, 45 | * images, videos and the bottom of their containers: 46 | * https://github.com/h5bp/html5-boilerplate/issues/440 47 | */ 48 | 49 | audio, 50 | canvas, 51 | iframe, 52 | img, 53 | svg, 54 | video { 55 | vertical-align: middle; 56 | } 57 | 58 | /* 59 | * Remove default fieldset styles. 60 | */ 61 | 62 | fieldset { 63 | border: 0; 64 | margin: 0; 65 | padding: 0; 66 | } 67 | 68 | /* 69 | * Allow only vertical resizing of textareas. 70 | */ 71 | 72 | textarea { 73 | resize: vertical; 74 | } 75 | 76 | /* ========================================================================== 77 | Browser Upgrade Prompt 78 | ========================================================================== */ 79 | 80 | .browserupgrade { 81 | margin: 0.2em 0; 82 | background: #ccc; 83 | color: #000; 84 | padding: 0.2em 0; 85 | } 86 | 87 | /* ========================================================================== 88 | Author's custom styles 89 | ========================================================================== */ 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | /* ========================================================================== 108 | Helper classes 109 | ========================================================================== */ 110 | 111 | /* 112 | * Hide visually and from screen readers 113 | */ 114 | 115 | .hidden { 116 | display: none !important; 117 | } 118 | 119 | /* 120 | * Hide only visually, but have it available for screen readers: 121 | * http://snook.ca/archives/html_and_css/hiding-content-for-accessibility 122 | */ 123 | 124 | .visuallyhidden { 125 | border: 0; 126 | clip: rect(0 0 0 0); 127 | height: 1px; 128 | margin: -1px; 129 | overflow: hidden; 130 | padding: 0; 131 | position: absolute; 132 | width: 1px; 133 | } 134 | 135 | /* 136 | * Extends the .visuallyhidden class to allow the element 137 | * to be focusable when navigated to via the keyboard: 138 | * https://www.drupal.org/node/897638 139 | */ 140 | 141 | .visuallyhidden.focusable:active, 142 | .visuallyhidden.focusable:focus { 143 | clip: auto; 144 | height: auto; 145 | margin: 0; 146 | overflow: visible; 147 | position: static; 148 | width: auto; 149 | } 150 | 151 | /* 152 | * Hide visually and from screen readers, but maintain layout 153 | */ 154 | 155 | .invisible { 156 | visibility: hidden; 157 | } 158 | 159 | /* 160 | * Clearfix: contain floats 161 | * 162 | * For modern browsers 163 | * 1. The space content is one way to avoid an Opera bug when the 164 | * `contenteditable` attribute is included anywhere else in the document. 165 | * Otherwise it causes space to appear at the top and bottom of elements 166 | * that receive the `clearfix` class. 167 | * 2. The use of `table` rather than `block` is only necessary if using 168 | * `:before` to contain the top-margins of child elements. 169 | */ 170 | 171 | .clearfix:before, 172 | .clearfix:after { 173 | content: " "; /* 1 */ 174 | display: table; /* 2 */ 175 | } 176 | 177 | .clearfix:after { 178 | clear: both; 179 | } 180 | 181 | /* ========================================================================== 182 | EXAMPLE Media Queries for Responsive Design. 183 | These examples override the primary ('mobile first') styles. 184 | Modify as content requires. 185 | ========================================================================== */ 186 | 187 | @media only screen and (min-width: 35em) { 188 | /* Style adjustments for viewports that meet the condition */ 189 | } 190 | 191 | @media print, 192 | (min-resolution: 1.25dppx), 193 | (min-resolution: 120dpi) { 194 | /* Style adjustments for high resolution devices */ 195 | } 196 | 197 | /* ========================================================================== 198 | Print styles. 199 | Inlined to avoid the additional HTTP request: 200 | http://www.phpied.com/delay-loading-your-print-css/ 201 | ========================================================================== */ 202 | 203 | @media print { 204 | *, 205 | *:before, 206 | *:after, 207 | *:first-letter, 208 | *:first-line { 209 | background: transparent !important; 210 | color: #000 !important; /* Black prints faster: 211 | http://www.sanbeiji.com/archives/953 */ 212 | box-shadow: none !important; 213 | text-shadow: none !important; 214 | } 215 | 216 | a, 217 | a:visited { 218 | text-decoration: underline; 219 | } 220 | 221 | a[href]:after { 222 | content: " (" attr(href) ")"; 223 | } 224 | 225 | abbr[title]:after { 226 | content: " (" attr(title) ")"; 227 | } 228 | 229 | /* 230 | * Don't show links that are fragment identifiers, 231 | * or use the `javascript:` pseudo protocol 232 | */ 233 | 234 | a[href^="#"]:after, 235 | a[href^="javascript:"]:after { 236 | content: ""; 237 | } 238 | 239 | pre, 240 | blockquote { 241 | border: 1px solid #999; 242 | page-break-inside: avoid; 243 | } 244 | 245 | /* 246 | * Printing Tables: 247 | * http://css-discuss.incutio.com/wiki/Printing_Tables 248 | */ 249 | 250 | thead { 251 | display: table-header-group; 252 | } 253 | 254 | tr, 255 | img { 256 | page-break-inside: avoid; 257 | } 258 | 259 | img { 260 | max-width: 100% !important; 261 | } 262 | 263 | p, 264 | h2, 265 | h3 { 266 | orphans: 3; 267 | widows: 3; 268 | } 269 | 270 | h2, 271 | h3 { 272 | page-break-after: avoid; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const babelify = require('babelify'); 4 | const browserify = require('browserify'); 5 | const browserSync = require('browser-sync').create(); 6 | const watchify = require('watchify'); 7 | const gulp = require('gulp'); 8 | const source = require('vinyl-source-stream'); 9 | const buffer = require('vinyl-buffer'); 10 | const eslint = require('gulp-eslint'); 11 | const babel = require('gulp-babel'); 12 | const autoprefixer = require('autoprefixer'); 13 | const concat = require('gulp-concat'); 14 | const cssmin = require('gulp-cssmin'); 15 | const postcss = require('gulp-postcss'); 16 | const stylus = require('gulp-stylus'); 17 | const gutil = require('gulp-util'); 18 | const uglify = require('gulp-uglify'); 19 | const mocha = require('gulp-mocha'); 20 | const sourcemaps = require('gulp-sourcemaps'); 21 | const plumber = require('gulp-plumber'); 22 | const runseq = require('run-sequence'); 23 | const rimraf = require('rimraf'); 24 | const child_process = require('child_process'); 25 | const serverConfig = require('./src/server/node_modules/common/server-config'); 26 | 27 | let watcher, b = browserify(Object.assign({}, watchify.args, { 28 | entries: ['./src/client/index.js'], 29 | plugins: [] 30 | })); 31 | 32 | function attachBrowserifyTransforms(br) { 33 | return br.transform(babelify, { 34 | global: true, // make sure to also transpile node_modules folders that we're using for shared components 35 | ignore: __dirname + '/node_modules' // don't transpile the root node_modules folder 36 | }); 37 | } 38 | 39 | function startWatchify() { 40 | watcher = attachBrowserifyTransforms(watchify(b)); 41 | watcher.on('update', changes => build.client()); 42 | watcher.on('log', gutil.log.bind(gutil, 'Browserify:')); 43 | } 44 | 45 | function debounce(fn, delay) { 46 | let id = 0; 47 | return function() { 48 | if(id) clearTimeout(id); 49 | id = setTimeout(() => { 50 | id = 0; 51 | fn(); 52 | }, delay); 53 | }; 54 | } 55 | 56 | const build = (() => { 57 | let next = {}; 58 | const add = (type, done) => (next[type] = done||true, start()); 59 | const start = debounce(function build() { 60 | const prebuilds = [], targets = [], postbuilds = [], callbacks = []; 61 | const addcb = done => typeof fn === 'function' && callbacks.push(done); 62 | let fullReload = false; 63 | if(next.client || next.server) { 64 | prebuilds.push('prebuild:lint:dev'); 65 | fullReload = true; 66 | } 67 | if(next.server) { 68 | targets.push('build:server'); 69 | postbuilds.push('reload:server'); 70 | fullReload = true; 71 | addcb(next.server); 72 | } 73 | if(next.client) { 74 | targets.push('build:client:dev'); 75 | fullReload = true; 76 | addcb(next.client); 77 | } 78 | if(next.assets) { 79 | postbuilds.push('postbuild:assets'); 80 | fullReload = true; 81 | addcb(next.assets); 82 | } 83 | if(next.styles) { 84 | postbuilds.push('build:styles:dev'); 85 | } 86 | postbuilds.push(fullReload ? 'reload:client' : 'reload:client:css'); 87 | next = {}; 88 | const tasks = [...prebuilds, targets, ...postbuilds].filter(t => t.length); 89 | runseq(...tasks, err => callbacks.forEach(fn => fn(err))); 90 | }, 250); 91 | return { 92 | client: done => add('client', done), 93 | assets: done => add('assets', done), 94 | server: done => add('server', done), 95 | styles: done => add('styles', done) 96 | }; 97 | })(); 98 | 99 | function initServer() { 100 | let child = null; 101 | const run = done => { 102 | if(child) { 103 | child.kill(); 104 | child = null; 105 | } 106 | else { 107 | child = child_process 108 | .fork('./dist/app/server/index.js') 109 | .on('exit', () => run()); 110 | } 111 | if(done) { 112 | done(); 113 | } 114 | }; 115 | return run; 116 | }; 117 | 118 | const loadBrowserSync = (() => { 119 | let started = false; 120 | function start(done) { 121 | browserSync.init({ 122 | proxy: `http://localhost:${serverConfig.port}`, 123 | open: false 124 | }, err => { 125 | if(!err) started = true; 126 | done(err); 127 | }); 128 | } 129 | const restart = (arg, done) => (browserSync.reload(arg), done()); 130 | return function(done) { return started ? restart(arguments[1], done) : start(done); }; 131 | })(); 132 | 133 | function buildClient(b, useUglify, done) { 134 | let stream = b.bundle() 135 | .on('error', done) 136 | .pipe(plumber()) 137 | .pipe(source('client.js')) 138 | .pipe(buffer()) 139 | .pipe(sourcemaps.init({loadMaps: true})); 140 | if(useUglify) 141 | stream = stream.pipe(uglify()); 142 | stream = stream 143 | .pipe(sourcemaps.write('./')) 144 | .pipe(gulp.dest('./dist/www/js')) 145 | .on('end', done); 146 | } 147 | 148 | function buildStyles(compress, done) { 149 | const autoprefixerOptions = { 150 | remove: false, 151 | browsers: ['> 3%', 'last 2 versions'] 152 | }; 153 | let stream = gulp.src('./src/styles/index.styl') 154 | .pipe(sourcemaps.init()) 155 | .pipe(stylus({ 156 | compress: !!compress, 157 | 'include css': true 158 | })) 159 | .pipe(postcss([ autoprefixer(autoprefixerOptions) ])) 160 | .pipe(concat('styles.css')); 161 | if(compress) 162 | stream = stream.pipe(cssmin()); 163 | stream = stream 164 | .pipe(sourcemaps.write('.')) 165 | .pipe(gulp.dest('./dist/www/css')) 166 | .on('end', done); 167 | } 168 | 169 | function runLinter(failAfterError) { 170 | let stream = gulp.src('./src/**/*.js') 171 | .pipe(plumber()) 172 | .pipe(eslint()) 173 | .pipe(eslint.format()); 174 | if(failAfterError) 175 | stream = stream.pipe(eslint.failAfterError()); // causes watchify to stop working for some reason 176 | return stream; 177 | } 178 | 179 | function runTests() { 180 | const slash = require('path').sep; 181 | const babelrc = Object.assign({ 182 | ignore: filename => filename.indexOf(`${slash}src${slash}`) > -1 183 | }, JSON.parse(require('fs').readFileSync('.babelrc'))); 184 | require('babel-register')(babelrc); 185 | return gulp.src(['./src/**/tests/**/*.js', './src/**/tests.js'], { read: false }) 186 | .pipe(mocha({ timeout: 5000 })); 187 | } 188 | 189 | function buildServer() { 190 | return gulp.src(['./src/**/*.js', '!./src/client/index.js', '!tests/**/*.js', '!tests.js']) 191 | .pipe(plumber()) 192 | // .pipe(buffer()) 193 | .pipe(sourcemaps.init({ loadMaps: true })) 194 | .pipe(babel()) 195 | .pipe(sourcemaps.write('./')) 196 | .pipe(gulp.dest('./dist/app')); 197 | } 198 | 199 | function copyAssets() { 200 | return gulp.src('./assets/**/*') 201 | .pipe(plumber()) 202 | .pipe(gulp.dest('./dist/www')); 203 | } 204 | 205 | function buildAndWatchAll(done) { 206 | startWatchify(); 207 | build.server(); 208 | build.client(); 209 | build.styles(); 210 | build.assets(done); 211 | } 212 | 213 | function buildForProduction(done) { 214 | runseq( 215 | 'prebuild:clean', 216 | 'prebuild:lint:prod', 217 | 'prebuild:tests', 218 | ['build:server', 'build:client:prod', 'build:styles:prod'], 219 | 'postbuild:assets', 220 | done 221 | ); 222 | } 223 | 224 | // ---------------------------------------------------------------------------- 225 | 226 | gulp.task('reload:server', initServer()); 227 | gulp.task('reload:client', loadBrowserSync); 228 | gulp.task('reload:client:css', done => loadBrowserSync(done, '*.css')); 229 | 230 | gulp.task('prebuild:clean', cb => rimraf('./dist', cb)); 231 | gulp.task('prebuild:lint:dev', () => runLinter(false)); 232 | gulp.task('prebuild:lint:prod', () => runLinter(true)); 233 | gulp.task('prebuild:tests', runTests); 234 | 235 | gulp.task('build:server', buildServer); 236 | gulp.task('build:client:dev', done => buildClient(watcher, false, done)); 237 | gulp.task('build:client:prod', done => buildClient(attachBrowserifyTransforms(b), true, done)); 238 | gulp.task('build:styles:dev', done => buildStyles(false, done)); 239 | gulp.task('build:styles:prod', done => buildStyles(true, done)); 240 | gulp.task('build:dev', ['prebuild:clean'], buildAndWatchAll); 241 | 242 | gulp.task('postbuild:assets', copyAssets); 243 | 244 | gulp.task('watch', () => { 245 | gulp.watch(['./src/client/**/*.js', '!./src/client/index.js', './src/server/**/*.js', './src/node_modules/**/*.js'], build.server); 246 | gulp.watch(['./src/**/*.styl'], build.styles); 247 | gulp.watch(['./assets/**'], build.assets); 248 | }); 249 | gulp.task('watch:tests', () => gulp.watch(['./src/**/*.js'], ['prebuild:tests'])); 250 | 251 | // ---------------------------------------------------------------------------- 252 | 253 | // run in isolation while writing unit tests 254 | gulp.task('test:dev', ['prebuild:tests', 'watch:tests']); 255 | 256 | // run when a deployment build is required 257 | gulp.task('build:dist', buildForProduction); 258 | 259 | // run for general development (does not include unit tests) 260 | gulp.task('default', ['build:dev', 'watch']); 261 | -------------------------------------------------------------------------------- /src/node_modules/common/tests/component-helpers-tests.js: -------------------------------------------------------------------------------- 1 | import most from 'most'; 2 | import {assert} from 'chai'; 3 | import {internals, partitionComponents, mergeSinks, consolidateChildren} from '../component-helpers'; 4 | import util from 'util'; 5 | 6 | /*eslint-disable no-unused-vars */ 7 | let startDate = Date.now(); 8 | const timeSig = () => Date.now() - startDate; 9 | const run = fn => new Promise(resolve => resolve(fn())); 10 | const $ = arr => ((arr.isStream = true), arr); 11 | const make$ = (arr, t) => most.periodic(t||10, 1).zip((t, x) => x, most.from(arr)); 12 | const inspect = (...args) => console.log.bind(console, `[${timeSig()}ms]`)(...args.map(arg => util.inspect(arg, false, 10, true))); 13 | /*eslint-enable */ 14 | 15 | function deepCapture(source) { 16 | return run(() => { 17 | if(source instanceof Array) return Promise.all(source.map(deepCapture)); 18 | if(source instanceof most.Stream) return source 19 | .reduce((acc, x) => (acc.push(Promise.resolve(deepCapture(x))), acc), []) 20 | .then(arr => Promise.all(arr).then($)); 21 | return source; 22 | }); 23 | } 24 | 25 | describe('app', () => { 26 | startDate = Date.now(); 27 | describe('#partitionComponents()', () => { 28 | 29 | it('should return an object with the properties `streams` and `statics`', () => { 30 | const result = partitionComponents({}); 31 | assert.property(result, 'streams'); 32 | assert.property(result, 'statics'); 33 | assert.isArray(result.streams); 34 | assert.isArray(result.statics); 35 | }); 36 | 37 | it('should return partition plain objects into `statics` and streams into `streams`', () => { 38 | const result = partitionComponents({ 39 | a: {x:1}, 40 | b: most.just(1), 41 | c: most.just(2), 42 | d: {y:2}, 43 | e: {z:3}, 44 | }); 45 | 46 | assert.lengthOf(result.statics, 3); 47 | assert.equal('a', result.statics[0].key); 48 | assert.equal('d', result.statics[1].key); 49 | assert.equal('e', result.statics[2].key); 50 | assert.deepEqual({x:1}, result.statics[0].sinks); 51 | assert.deepEqual({y:2}, result.statics[1].sinks); 52 | assert.deepEqual({z:3}, result.statics[2].sinks); 53 | 54 | assert.lengthOf(result.streams, 2); 55 | assert.equal('b', result.streams[0].key); 56 | assert.equal('c', result.streams[1].key); 57 | assert.equal(1, result.streams[0].sinks$.source.value); 58 | assert.equal(2, result.streams[1].sinks$.source.value); 59 | }); 60 | }); 61 | 62 | describe('#projectSinkFromComponentStream()', () => { 63 | 64 | it('should extract a stream of the values for the specified sink key', () => { 65 | const sinks$ = most.from([ 66 | { foo: most.from([1, 2]), bar: most.from([10, 11]) }, 67 | { foo: most.from([4, 5]), bar: most.from([13, 14]) }, 68 | { foo: most.from([7, 8]), bar: most.from([16, 17]) } 69 | ]); 70 | const foo$ = internals.projectSinkFromComponentStream('foo', sinks$); 71 | assert.instanceOf(foo$, most.Stream); 72 | return deepCapture(foo$) 73 | .then(result => { 74 | const expected = $([7, 8]); 75 | assert.deepEqual(result, expected); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('#mergeSinks()', () => { 81 | 82 | const sinks = { 83 | a: { 84 | x: most.from([1, 2]), 85 | y: most.from([3, 4]), 86 | z: most.from([5, 6]) 87 | }, 88 | b: { 89 | x: most.from([10, 11]), 90 | y: most.from([12, 13]), 91 | z: most.from([14, 15]) 92 | }, 93 | c: most.from([ 94 | { 95 | x: most.from([20, 21]), 96 | y: most.from([22, 23]), 97 | z: most.from([24, 25]), 98 | }, 99 | { 100 | x: most.from([30, 31]), 101 | z: most.from([32, 33]), 102 | }, 103 | { 104 | y: most.from([34, 35]), 105 | } 106 | ]), 107 | d: most.from([ 108 | { 109 | y: most.from([220, 230]), 110 | z: most.from([240, 250]), 111 | }, 112 | { 113 | x: most.from([500, 501]), 114 | a: most.from([502, 503]), 115 | k: most.from([504, 505]), 116 | } 117 | ]) 118 | }; 119 | const partitioned = partitionComponents(sinks); 120 | const merged = mergeSinks(['x', 'y'], partitioned); 121 | 122 | it('should merge the specified sinks from multiple components into a single set of output sinks', () => { 123 | assert.isObject(merged); 124 | assert.property(merged, 'x'); 125 | assert.property(merged, 'y'); 126 | assert.notProperty(merged, 'z'); 127 | assert.notProperty(merged, 'k'); 128 | assert.notProperty(merged, 'a'); 129 | assert.instanceOf(merged.x, most.Stream); 130 | assert.instanceOf(merged.y, most.Stream); 131 | }); 132 | 133 | it('should yield the set of values from each of the merged streams', () => { 134 | return deepCapture(merged.x) 135 | .then(result => { 136 | assert.deepEqual(result, $([1, 2, 10, 11, 30, 31, 500, 501])); 137 | return deepCapture(merged.y); 138 | }) 139 | .then(result => { 140 | assert.deepEqual(result, $([3, 4, 12, 13, 34, 35, 220, 230])); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('#buildStateStream()', () => { 146 | const sinks = { 147 | d: make$([ 148 | { state: make$([ 149 | { x: 10, y: 11 }, // immediate 150 | { x: 12, y: 13 } // 70ms 151 | ], 70)}, 152 | { state: make$([ 153 | { x: 14, y: 15 }, // 50ms 154 | { x: 16, y: 17 } // 60ms 155 | ])} 156 | ], 50), 157 | e: make$([ 158 | { state: make$([{ y: 18 }]) }, // immediate 159 | { state: make$([{ y: 19 }]) } // 10ms 160 | ]), 161 | a: { state: make$([ 162 | { x: 1, y: 2 }, // immediate 163 | { x: 3, y: 4 }, // 30ms 164 | { x: 3.5, y: 4.5 } 165 | ], 30)}, 166 | b: { state: make$([ 167 | { x: 5, y: 6 }, // immediate 168 | { x: 7, y: 8 } // 10ms 169 | ])}, 170 | c: {} // immediate 171 | }; 172 | const partitioned = partitionComponents(sinks); 173 | 174 | it('buildPairStreamArray() should return an array of streams of key/state objects', () => { 175 | const pair$arr = internals.buildPairStreamArray(partitioned.statics); 176 | return deepCapture(pair$arr) 177 | .then(result => { 178 | const expected = [ 179 | $([{ key: 'a', state: { x: 1, y: 2 }}, { key: 'a', state: { x: 3, y: 4 }}, { key: 'a', state: { x: 3.5, y: 4.5 }}]), 180 | $([{ key: 'b', state: { x: 5, y: 6 }}, { key: 'b', state: { x: 7, y: 8 }}]), 181 | $([{ key: 'c', state: { }}]) 182 | ]; 183 | assert.deepEqual(result, expected); 184 | }); 185 | }); 186 | 187 | it('buildHigherOrderPairStreamArray() should return an array of higher-order streams of key/state pairs', () => { 188 | const pair$$arr = internals.buildHigherOrderPairStreamArray(partitioned.streams); 189 | return deepCapture(pair$$arr) 190 | .then(result => { 191 | const expected = [ 192 | $([ 193 | $([{ key: 'd', state: { x:10, y:11 }}, { key: 'd', state: { x:12, y:13 }}]), 194 | $([{ key: 'd', state: { x:14, y:15 }}, { key: 'd', state: { x:16, y:17 }}]) 195 | ]), 196 | $([ 197 | $([{ key: 'e', state: { y: 18 }}]), 198 | $([{ key: 'e', state: { y: 19 }}]) 199 | ]) 200 | ]; 201 | assert.deepEqual(result, expected); 202 | }); 203 | }); 204 | 205 | it('makeChannelPerSinkKey() should combine pair$arr and pair$$arr into a single array of pair streams', () => { 206 | const pair$arr = internals.buildPairStreamArray(partitioned.statics); 207 | const pair$$arr = internals.buildHigherOrderPairStreamArray(partitioned.streams); 208 | const channels = internals.makeChannelPerSinkKey(pair$$arr, pair$arr); 209 | function makePairStream(key, ...states) { return states.map(state => ({ key, state })); } 210 | function fn(key) { return makePairStream.bind(null, key); } 211 | const a = fn('a'), b = fn('b'), c = fn('c'), d = fn('d'), e = fn('e'); 212 | return deepCapture(channels) 213 | .then(result => { 214 | const expected = [ 215 | $(d({ x: 10, y: 11 }, { x: 14, y: 15 }, { x: 16, y: 17 })), 216 | $(e({ y: 18 }, { y: 19 })), 217 | $(a({ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 3.5, y: 4.5 })), 218 | $(b({ x: 5, y: 6 }, { x: 7, y: 8 })), 219 | $(c({})) 220 | ]; 221 | assert.deepEqual(result, expected); 222 | }); 223 | }); 224 | 225 | it('buildStateStreamFromChannels() should return a stream of state objects assembled from the underlying channels', () => { 226 | const pair$arr = internals.buildPairStreamArray(partitioned.statics); 227 | const pair$$arr = internals.buildHigherOrderPairStreamArray(partitioned.streams); 228 | const channels = internals.makeChannelPerSinkKey(pair$$arr, pair$arr); 229 | const state$ = internals.buildStateStreamFromChannels(channels); 230 | return deepCapture(state$) 231 | .then(result => { 232 | const expected = $([ 233 | { 234 | a: { x: 1, y: 2 }, 235 | b: { x: 5, y: 6 }, 236 | c: {}, 237 | d: { x: 10, y: 11 }, 238 | e: { y: 18 } 239 | }, { 240 | a: { x: 1, y: 2 }, 241 | b: { x: 7, y: 8 }, 242 | c: {}, 243 | d: { x: 10, y: 11 }, 244 | e: { y: 18 } 245 | }, { 246 | a: { x: 1, y: 2 }, 247 | b: { x: 7, y: 8 }, 248 | c: {}, 249 | d: { x: 10, y: 11 }, 250 | e: { y: 19 } 251 | }, { 252 | a: { x: 3, y: 4 }, 253 | b: { x: 7, y: 8 }, 254 | c: {}, 255 | d: { x: 10, y: 11 }, 256 | e: { y: 19 } 257 | }, { 258 | a: { x: 3, y: 4 }, 259 | b: { x: 7, y: 8 }, 260 | c: {}, 261 | d: { x: 14, y: 15 }, 262 | e: { y: 19 } 263 | }, { 264 | a: { x: 3.5, y: 4.5 }, 265 | b: { x: 7, y: 8 }, 266 | c: {}, 267 | d: { x: 14, y: 15 }, 268 | e: { y: 19 } 269 | }, { 270 | a: { x: 3.5, y: 4.5 }, 271 | b: { x: 7, y: 8 }, 272 | c: {}, 273 | d: { x: 16, y: 17 }, 274 | e: { y: 19 } 275 | } 276 | ]); 277 | assert.lengthOf(result, 7); 278 | assert.deepEqual(result, expected); 279 | }); 280 | }); 281 | 282 | it('buildStateStreamFromChannels() should should still work if there are only streams of sinks', () => { 283 | const partitioned = partitionComponents({ d: sinks.d, e: sinks.e }); 284 | const pair$arr = internals.buildPairStreamArray(partitioned.statics); 285 | const pair$$arr = internals.buildHigherOrderPairStreamArray(partitioned.streams); 286 | const channels = internals.makeChannelPerSinkKey(pair$$arr, pair$arr); 287 | const state$ = internals.buildStateStreamFromChannels(channels); 288 | return deepCapture(state$) 289 | .then(result => { 290 | const expected = $([ 291 | { 292 | d: { x: 10, y: 11 }, 293 | e: { y: 18 } 294 | }, { 295 | d: { x: 10, y: 11 }, 296 | e: { y: 19 } 297 | }, { 298 | d: { x: 14, y: 15 }, 299 | e: { y: 19 } 300 | }, { 301 | d: { x: 16, y: 17 }, 302 | e: { y: 19 } 303 | } 304 | ]); 305 | assert.lengthOf(result, 4); 306 | assert.deepEqual(result, expected); 307 | }); 308 | }); 309 | 310 | it('buildStateStreamFromChannels() should should still work if there are only static sinks', () => { 311 | const partitioned = partitionComponents({ a: sinks.a, b: sinks.b, c: sinks.c }); 312 | const pair$arr = internals.buildPairStreamArray(partitioned.statics); 313 | const pair$$arr = internals.buildHigherOrderPairStreamArray(partitioned.streams); 314 | const channels = internals.makeChannelPerSinkKey(pair$$arr, pair$arr); 315 | const state$ = internals.buildStateStreamFromChannels(channels); 316 | return deepCapture(state$) 317 | .then(result => { 318 | const expected = $([ 319 | { 320 | a: { x: 1, y: 2 }, 321 | b: { x: 5, y: 6 }, 322 | c: {} 323 | }, { 324 | a: { x: 1, y: 2 }, 325 | b: { x: 7, y: 8 }, 326 | c: {} 327 | }, { 328 | a: { x: 3, y: 4 }, 329 | b: { x: 7, y: 8 }, 330 | c: {} 331 | }, { 332 | a: { x: 3.5, y: 4.5 }, 333 | b: { x: 7, y: 8 }, 334 | c: {} 335 | } 336 | ]); 337 | assert.lengthOf(result, 4); 338 | assert.deepEqual(result, expected); 339 | }); 340 | }); 341 | }); 342 | 343 | describe('#consolidateChildren()', () => { 344 | 345 | it('should return an object of { sinks, state$ }', () => { 346 | const sinks = {}; 347 | const result = consolidateChildren(sinks, ['a', 'b']); 348 | assert.property(result, 'sinks'); 349 | assert.property(result, 'state$'); 350 | assert.isObject(result.sinks); 351 | assert.property(result.sinks, 'a'); 352 | assert.property(result.sinks, 'b'); 353 | assert.instanceOf(result.state$, most.Stream); 354 | }); 355 | 356 | }); 357 | 358 | }); 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Epicycle** :: node.js + Motorcycle.js == Isomorphism 2 | ================================================== 3 | 4 | Epicycle is a foundational/boilerplate project for building reactive, isomorphic applications with [Motorcycle.js](https://github.com/motorcyclejs/core) and [node.js](https://nodejs.org/en/). 5 | 6 | _NOTE: This project is a work in progress. I've been dogfooding it for a small SaaS application I've been building and in the process have improved and refactored a large quantity of the existing code. The foundational stuff showing separation between server and client code should be fine for ideas, but a commit in the near future will overhaul a lot of what you see here. I'd suggest starring the repository and waiting until the next commit, if you're interested in using this for your own work._ 7 | 8 | ## Introduction 9 | 10 | Starting a new web application these days is annoyingly overburdened with setup and ceremony, not to mention all of the repetitive groundwork required to establish a solid foundation for the rest of the application to be built upon. Depending on what you're trying to do, it can take anywhere from hours to days to get to a point where you're writing your actual project-specific code. This project is designed to take that pain out of the equation and give you the fastest possible starting point when developing a new isomorphic web application using [Motorcycle.js](https://github.com/motorcyclejs/core). 11 | 12 | #### Why Motorcycle? 13 | 14 | Simply put, it's ridiculously fast, while being idiomatically the same as [Cycle.js](http://cycle.js.org/). So if you know Cycle, you should be able to get into Motorcycle pretty easily. The underlying streams library is [Most](https://github.com/cujojs/most), which is a little different in behaviour to to RxJs, but it's literally two orders of magnitude faster, so the small learning curve is worth it. 15 | 16 | #### Build Tools 17 | 18 | It is impossible to be unopinionated when it comes to build tools, because there are many of them and each has its pros and cons. The project uses [gulp](http://gulpjs.com/), [Browserify](http://browserify.org/), [BrowserSync](http://www.browsersync.io/), [Babel](http://babeljs.io/), [eslint](http://eslint.org/) and [mocha](https://mochajs.org/) for testing. It also comes bundled with basic [Stylus](https://github.com/stylus/stylus) support by default, but it's very easy to swap out if you prefer something such as Sass, Less or inline JavaScript-based styles. Obviously, for the purposes of isomorphism, you'll need to run node.js on your production server, though you may wish to bulletproof it with a process manager [such as this one](http://strong-pm.io/). 19 | 20 | Remember, Epicycle is more boilerplate than it is framework. You should change as much of it as you like to suit your personal development preferences. It does come with a base library of helpers to facilitate the differences between "widget" components and "page" components, particularly with respect to concerns such as HTTP status codes, document titles, etc., which you'll spend a lot of time implementing support for if you try to do it yourself. If you're ok with the chosen defaults though, you should be able to hit the ground running and start building your application straight away. 21 | 22 | - [Introduction](#introduction) 23 | - [Features](#features) 24 | - [What's an isomorphic web application?](#whats-an-isomorphic-web-application) 25 | - [Setup & Installation](#setup--installation) 26 | - [Unit Tests](#unit-tests) 27 | - [Usage and Best Practices](#usage-and-best-practices) 28 | - [The State Driver, Component Helpers and Routing](#the-state-driver-component-helpers-and-routing) 29 | - [Roadmap](#roadmap) 30 | 31 | ## Features 32 | 33 | - Complete isomorphic foundation for bootstrapping Motorcycle-based client applications 34 | - Designed to be easy to break apart later for scalability 35 | - Clean shared component references allowing for later extraction to separate npm packages 36 | - One-stop-shop build process for development, testing and deployment builds 37 | - Router integration and page-specific metadata for enabling correct HTTP status codes 38 | 39 | ## What's an isomorphic web application? 40 | 41 | In this day and age, with so many advanced JavaScript-based front-end frameworks to choose from, it's natural to want to build our user interface so that it runs entirely on the client, and make subsequent requests for data to a clean server-side API. True separation of concerns! Several problems emerge though: 42 | 43 | 1. **The time it takes to render the initial view can be quite noticeable**, as the full JavaScript must first be downloaded before it can run, and then a second round trip may be required back the server to retrieve the initial data that is needed. This impacts the user experience to a varying degree depending on the user's latency and bandwidth. 44 | 2. **It's not great for search engine indexability and rankings**. Google is reportedly able to crawl sites that run JavaScript, but they're also known to optimize results with initial rendering speed in mind, so a slow-to-start site is going to suffer in search rankings. 45 | 3. **Proper URIs with different paths are harder to make use of**. Path-based URIs for each route often end up being avoided in favour of hash-based URIs. This happens because if we change the URI path based on client application state and then try to reload the page later at that same URI, either we'll get a 404 response because it's not the page we set up to bootstrap the application, or the server won't easily be able to determine if that URI is valid, thus necessitating a blanket 200 response for every request, which can have unintended side effects when the route was actually invalid. 46 | 47 | An isomorphic app solves all of these problems. For any initial request, it runs the client application server-side and captures the output. It then offers up a rendering of the initial state of the DOM, along with an appropriate HTTP status code depending on the validity of that request. In this way, the user sees content almost immediately (even if it takes a little longer for client-side functionality to boot up), search engines will likely do a better job of indexing the page and favouring it due to a fast response time, and the user can bookmark, link and share any URI that matches a valid route for the application. As a bonus, it also becomes possible to make forms and links work even if the app didn't load correctly in the client's browser, for whatever reason. 48 | 49 | ## Setup & Installation 50 | 51 | Clone the repository or [download a zip of the source code](https://github.com/axefrog/epicycle/archive/master.zip) and extract it to your development directory, then run: 52 | 53 | ```text 54 | npm install 55 | ``` 56 | 57 | Make sure you have gulp installed globally too, as you'll need it to build the project from the command line. 58 | 59 | ```text 60 | npm install -g gulp 61 | ``` 62 | 63 | > **Note**: If you're using the [Atom](http://atom.io/) editor with [Atom Linter](https://atom.io/packages/linter), you'll also want to install the [eslint plugin](https://github.com/AtomLinter/linter-eslint) and make sure _"Use global ESLINT installation"_ is checked. Not doing so can cause the linter to trip up. If you do, make sure [eslint](https://www.npmjs.com/package/eslint) and [babel-eslint](https://www.npmjs.com/package/babel-eslint) are both installed globally as well: 64 | > ``` 65 | > npm install -g eslint babel-eslint 66 | > ``` 67 | > Note also that the project .eslintrc file is preconfigured to use babel-eslint. 68 | 69 | Finally, to build and run the application: 70 | 71 | ```text 72 | gulp 73 | ``` 74 | 75 | The build process will start a local node.js dev server and a BrowserSync process, proxied to the dev server, and start watching your source files for changes. 76 | 77 | To view the application, open [http://localhost:3000](http://localhost:3000) in your browser. If you're already running BrowserSync for another project, the port may be different, so pay attention to the gulp console output for the actual port number. 78 | 79 | **For a production build:** 80 | 81 | ```text 82 | gulp build:dist 83 | ``` 84 | 85 | Client bundles are compressed and minified for production builds only, as the refresh time is otherwise too slow when making many changes during development. Unit tests are also run when building for production in order to ensure that the build fails if any tests fail. 86 | 87 | ## Unit Tests 88 | 89 | Mocha and Chai are integrated by default. As always, replacing them should be easy enough if you prefer to use something else. Your tests can be placed within the _src_ directory, as long as they exist at some depth within a _tests_ folder (you can have many _tests_ folders) or a file named _tests.js_ (again, as many of these as you like). In this way, you can have your unit tests bundled with each component, which makes it easy to keep related files together. 90 | 91 | Tests are disabled by default for the main gulp watch task in order to reduce rebuild/reload times during development, but if you're writing tests, you should run the test watcher, which quickly rebuilds and reruns tests when any changes are made. For the best of both worlds, run two terminals/shells/consoles; one for the test watcher and one for the default gulp development task. 92 | 93 | **To start the test watcher:** 94 | 95 | ```text 96 | gulp test:dev 97 | ``` 98 | 99 | ## Usage and Best Practices 100 | 101 | Because we're generally using the same code on both the server and client, it's easy to get confused and wonder how we're supposed to set up our Motorcycle drivers, given that operations such as data retrieval are thought about differently on the server than they are on the client. If we're trying to unify our code so that it works on both the client _and_ the server, what do we do? 102 | 103 | While the isomorphic mantra tells us that the client and the server are effectively the same, in actual fact that's not quite the truth. **Our Motorcycle.js app is still fundamentally designed to be run on the client**. The fact that we're pre-rendering the initial view on the server is just a bonus we get from being able to run JavaScript on the server. **The initial server-side page renderer will simply pretend it's the client**. It'll make any necessary HTTP (or other) requests that it needs to, and the fact that those requests are being directed at the very server from which they originated is not something we need to worry about. 104 | 105 | ### Planning for increasing load and a growing code-base 106 | 107 | As we grow our application and separate concerns, we start to realise that UI-specific requests, such as page rendering, are separate from non-UI-specific requests, such as data lookup and server state mutation. In the past, these will have all very commonly been handled by a single server-based application (think MVC and similar patterns). A presentation tier will have handled UI-specific requests and responded with new representations of the user interface (pure server-side rendering), and a business tier will have handled requests to retrieve data and mutate state. In our case, all we're doing is accounting for the fact that we've moved almost the entire presentation tier to the client and then had it bridge the gap to our business tier via standard HTTP requests (XMLHttpRequest/Fetch/WebSockets). 108 | 109 | The bottom line is that our server-side rendering code will very likely end up making API calls via HTTP requests, even though the requests are being made to the same server it's running on, *and this is fine*. When we begin to scale our operations later and direct our pure API calls to a different server and/or (sub)domain, our client application code will thus be impacted minimally. 110 | 111 | ### Regarding secondary node_modules folders 112 | 113 | You'll notice the use of node_modules folders *within the source*. This is optional of course, but consider the benefits. The standard path resolution algorithm for module loading is [described here](https://nodejs.org/api/modules.html#modules_loading_from_node_modules_folders) and due to the way it works, it gives us a very easy way to share common components within our application without hacking around with the way `require(...)` works (a dangerous game when we want to interoperate with external services, build tools and so forth). It also simplifies our process by avoiding the need to mess around with symlinks, which can be a pain, especially when dealing with multiple environments and operating systems. 114 | 115 | **Do not confuse this use of node_modules with the external dependencies installed by npm**. In our *.gitignore* file, we ignore only the root node_modules folder, which is where npm will be putting external dependencies, and we allow node_modules folders elsewhere. In essence, just think of these descendant node_modules folders as shared components folders that can be easily and cleanly referenced within our application. This solves the nasty problem of parent path hell (`require("../../../../../../components/foo")`) and gives us a way to both share components that are area-specific, in addition to those that are used sitewide. Take a look at [Ryan Florence's folder layout article](https://gist.github.com/ryanflorence/daafb1e3cb8ad740b346), which was the inspiration for this decision. Note that, because we're using `node_modules` and not some build-tool-specific hackery of the `require` function, it means that at a later date it's trivial to separate common components and helpers into an external npm package in order to allow them to be shared among multiple projects. 116 | 117 | ## The State Driver, Component Helpers and Routing 118 | 119 | A simple client-side application has few concerns other than the need to wire a few user interface components together. A large application however must be able to deal with URLs and the concept of "pages". These bring with them additional concerns, such as: 120 | 121 | - Returning a correct HTTP status code on the first request 122 | - Changing the document title when the URL is changed locally (pushed to history) 123 | - Propagation of general page-related metadata that may be nested several layers deep 124 | - Potential serialization of page information when an alternate content type is requested (content negotiation) 125 | 126 | A new "state" driver has been created to help solve these issues. State is designed to emit snapshots of, obviously, the evolving state of the application as it reacts to changing inputs. 127 | 128 | *TODO: The rest of this section. Look at the existing code in src/app for example usage. Look in src/node_modules/common for the implementations of the patterns used in this project.* 129 | 130 | ## Roadmap 131 | 132 | ### Functionality 133 | 134 | - demonstrate nested titles 135 | - tidy up example application structure (with neutral/minimalist styling) 136 | - subnavigation example 137 | - @cycle/isolate 138 | - add cache busting to client.js href (hash the file contents) 139 | - resolve multiple page emissions per route change (will need visualiser for this) 140 | - consider content negotiation 141 | 142 | ### Build Process 143 | 144 | - fix uglify processing: https://github.com/gulpjs/gulp/blob/master/docs/recipes/browserify-uglify-sourcemap.md 145 | --------------------------------------------------------------------------------