diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fe99c89 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +## `2.0.0` +### breaking changes +- namespaces are now enforced more strictly +- models now only accept a single argument +- the `namespace` key was introduced inside of models (was prior the leading + string in models) +- namespaced models can now only operate within themselves + +## `1.0.0` +- first version of choo diff --git a/README.md b/README.md index 0136ad7..73254c8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ productive package. - [Demos](#demos) - [Usage](#usage) - [Concepts](#concepts) + - [Models](#models) - [Effects](#effects) - [HTTP](#http) - [Subscriptions](#subscriptions) @@ -23,6 +24,7 @@ productive package. - [API](#api) - [FAQ](#faq) - [Installation](#installation) +- [See Also](#see-also) - [License](#license) ## Features @@ -51,28 +53,31 @@ productive package. const choo = require('choo') const app = choo() -app.model('title', { +app.model({ + namespace: 'input', state: { - title: 'my-demo-app' + title: 'my demo app' }, reducers: { - 'update': (action, state) => ({ title: action.payload }) + update: (action, state) => ({ title: action.payload }) }, effects: { - 'update': (action, state, send) => (document.title = action.payload) + update: (action, state, send) => (document.title = action.payload) } }) -const mainView = (params, state, send) => choo.view` -
-

${state.title}

- - send('title:update', { payload: e.target.value })}> -
-` +const mainView = (params, state, send) => { + return choo.view` +
+

${state.input.title}

+ + send('input:update', { payload: e.target.value })}> +
+ ` +} app.router((route) => [ route('/', mainView) @@ -88,8 +93,8 @@ document.body.appendChild(tree) your browser - __actions:__ a named event with optional properties attached. Used to call `effects` and `reducers` that have been registered in `models` -- __model:__ optionally namespaced object containing `subscriptions`, `effects` - and `reducers` +- __model:__ optionally namespaced object containing `subscriptions`, + `effects`, `reducers` and initial `state` - __subscriptions:__ read-only data sources that emit `actions` - __effects:__ asynchronous functions that emit an `action` when done - __reducers:__ synchronous functions that modify `state` @@ -116,6 +121,41 @@ document.body.appendChild(tree) └────────┘ └────────┘ ``` +## Models +`models` are objects that contain initial `state`, `subscriptions`, `effects` +and `reducers`. They're generally grouped around a theme (or domain, if you +like). To provide some sturdiness to your `models`, they can either be +namespaced or not. Namespacing means that only state within the model can be +accessed. Models can still trigger actions on other models, though it's +recommeded to keep that to a minimum. + +So say we have a `todos` namespace, an `add` reducer and a `todos` model. +Outside the model they're called by `send('todos:add')` and +`state.todos.todos`. Inside the namespaced model they're called by +`send('todos:add')` and `state.todos`. An example namespaced model: +```js +const app = choo() +app.model({ + namespace: 'todos', + model: { todos: [] }, + reducers: { + add: (state, action) => ({ todos: state.todos.concat(action.payload) }) + } +}) +``` + +In most cases using namespaces is beneficial, as having clear boundries makes +it easier to follow logic. But sometimes you need to call `actions` that +operate over multiple domains (such as a "logout" `action`), or have a +`subscription` that might trigger multiple `reducers` (such as a `websocket` +that calls a different `action` based on the incoming data). + +In these cases you probably want to have a `model` that doesn't use namespaces, +and has access to the full application state. Try and keep the logic in these +`models` to a minimum, and declare as few `reducers` as possible. That way the +bulk of your logic will safely shielded, with only a few points touching every +part of your application. + ## Effects Side effects are done through `effects` declared in `app.model()`. Unlike `reducers` they cannot modify the state by returning objects, but get a @@ -136,25 +176,49 @@ A typical `effect` flow looks like: that weighs only `2.4kb`: ```js const http = require('choo/http') +const choo = require('choo') +const app = choo() -// GET JSON -http.get('/my-endpoint', { json: true }, function (err, res, body) { - if (err) throw err - if (res.statusCode !== 200 || !body) throw new Error('something went wrong') +app.model({ + effects: { + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`http: ${event.payload}`), + 'http:get_json': getJson, + 'http:post_json': postJson, + 'http:delete': httpDelete + } }) -// POST JSON -const body = { foo: 'bar' } -http.post('/my-endpoint', { json: body }, function (err, res, body) { - if (err) throw err - if (res.statusCode !== 200 || !body) throw new Error('something went wrong') -}) +function getJson (state, action, send) { + http.get('/my-endpoint', { json: true }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200 || !body) { + return send('app:error', { payload:'something went wrong' }) + } + send('app:print', { payload: body }) + }) +} -// DELETE -http.del('/my-endpoint', function (err, res) { - if (err) throw err - if (res.statusCode !== 200) throw new Error('something went wrong') -}) +function postJson (state, action, send) { + const body = { foo: 'bar' } + http.post('/my-endpoint', { json: body }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200 || !body) { + return send('app:error', { payload:'something went wrong' }) + } + send('app:print', { payload: body }) + }) +} + +function httpDelete (state, action, send) { + const body = { foo: 'bar' } + http.post('/my-endpoint', { json: body }, function (err, res, body) { + if (err) return send('app:error', { payload: err.message }) + if (res.statusCode !== 200) { + return send('app:error', { payload:'something went wrong' }) + } + }) +} ``` Note that `http` only runs in the browser to prevent accidental requests when rendering in Node. For more details view the [`raynos/xhr` @@ -165,6 +229,19 @@ Subscriptions are a way of receiving data from a source. For example when listening for events from a server using `SSE` or `Websockets` for a chat app, or when catching keyboard input for a videogame. +An example subscription that logs `"dog?"` every second: +```js +const app = choo() +choo.model({ + subscriptions: [ + (send) => setTimeout(() => send('app:print', { payload: 'dog?' }), 1000) + ], + effects: { + 'app:print': (state, action) => console.log(action.payload) + } +}) +``` + ### Server Sent Events (SSE) [Server Sent Events (SSE)][sse] allow servers to push data to the browser. They're the unidirectional cousin of `websockets` and compliment `HTTP` @@ -176,17 +253,20 @@ const stream = new document.EventSource('/sse') app.model({ subscriptions: [ function (send) { - stream.onerror = (e) => send('error', { payload: JSON.stringify(e) }) - stream.onmessage = (e) => send('print', { payload: e.data }) + stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) }) + stream.onmessage = (e) => send('app:print', { payload: e.data }) } ], effects: { 'sse:close': () => stream.close() - error: (state, event_ => console.error(`error: ${event.payload}`)), - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`sse: ${event.payload}`) } }) ``` +This code does not handle reconnects, server timeouts, exponential backoff and +queueing data. You might want to use a package from `npm` or [write your +own][sse-reconnect] if you're building something for production. ### Keyboard Most browsers have [basic support for keyboard events][keyboard-support]. To @@ -195,11 +275,11 @@ capture keyboard events, setup a `subscription`: app.model({ subscriptions: [ function (send) { - keyboard.onkeypress = (e) => send('print', { payload: e.keyCode }) + keyboard.onkeypress = (e) => send('app:print', { payload: e.keyCode }) } ], effects: { - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:print': (state, event) => console.log(`pressed key: ${event.payload}`) } }) ``` @@ -213,18 +293,21 @@ const socket = new document.WebSocket('ws://localhost:8081') app.model({ subscriptions: [ function (send) { - socket.onerror = (e) => send('error', { payload: JSON.stringify(e) }) - socket.onmessage = (e) => send('print', { payload: e.data }) + socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) }) + socket.onmessage = (e) => send('app:print', { payload: e.data }) } ], effects: { 'ws:close': () => socket.close(), 'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)), - error: (state, event_ => console.error(`error: ${event.payload}`)), - print: (state, event) => console.log(`pressed key num: ${event.payload}`) + 'app:error': (state, event_ => console.error(`error: ${event.payload}`)), + 'app:print': (state, event) => console.log(`ws: ${event.payload}`) } }) ``` +This code does not handle reconnects, server timeouts, exponential backoff and +queueing data. You might want to use a package from `npm` or [write your +own][ws-reconnect] if you're building something for production. ## Rendering in Node Sometimes it's necessary to render code inside of Node; for serving first @@ -273,9 +356,12 @@ else document.body.appendChild(app.start()) ### app = choo() Create a new `choo` app -### app.model(name?, obj) -Create a new model. Models modify data and perform IO. Obj takes the following +### app.model(obj) +Create a new model. Models modify data and perform IO. Takes the following arguments: +- __namespace:__ optional namespace that prefixes the keys in `state`, + `reducers` and `effects`. Also limits `actions` called by `send()` to + in-namespace only. - __state:__ object. Key value store of initial values - __reducers:__ object. Syncronous functions that modify state. Each function has a signature of `(action, state)` @@ -283,10 +369,6 @@ arguments: a signature of `(action, state, send)` where `send` is a reference to `app.send()` -If a `name` string is passed as a first argument, `reducers` and `signatures` -will be prefixed by the name. So if name is "user" and a reducer called -"update" is registered, it would be accessed as `'user:update'` in `send()`. - ### choo.view\`html\` Tagged template string HTML builder. See [`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views @@ -316,6 +398,32 @@ Start the application. Returns a tree of DOM nodes that can be mounted using accordingly. ## FAQ +### Why did you build this? +`choo` is nothing but a formalization of how I've been building my applications +for the past year. I originally used `virtual-dom` with `virtual-app` and +`wayfarer` where now it's `yo-yo` with `send-action` and `sheet-router`. The +main benefit of using `choo` over these technologies separately is that it +becomes easier for teams to pick up and gather around. The code base for `choo` +itself is super petite (`~150` LOC) and mostly acts to enforce structure around +some excellent npm packages. This is my take on modular frameworks; I hope +you'll find it pleasant. + +### Why is it called choo? +Because I thought it sounded cute. All these programs talk about being +"performant", "rigid", "robust" - I like programming to be light, fun and +non-scary. `choo` embraces that. + +Also imagine telling some business people you chose to rewrite something +critical to the company using the `choo` framework. +:steam_locomotive::train::train::train: + +### Why is it a framework, and not a library? +I love small libraries that do one thing well, but when working in a team, +having an undocumented combination of packages often isn't great. `choo()` is a +small set of packages that work well together, wrapped in an an architectural +pattern. This means you get all the benefits of small packages, but get to be +productive right from the start. + ### How does choo compare to X? Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_) was built because other options didn't quite cut it for me, so instead of @@ -382,6 +490,10 @@ transforms: - [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using UglifyJS2. Use as a `--global` transform +## Hey, doesn't this look a lot like Elm? +Yup, it's greatly inspired by the `elm` architecture. But contrary to `elm`, +`choo` doesn't introduce a completely new language to build web applications. + ### Is it production ready? Sure. @@ -390,6 +502,11 @@ Sure. $ npm install choo ``` +## See Also +- [budo](https://github.com/mattdesl/budo) - quick prototyping tool for + `browserify` +- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL + ## License [MIT](https://tldrlegal.com/license/mit-license) @@ -416,3 +533,5 @@ $ npm install choo [morphdom]: https://github.com/patrick-steele-idem/morphdom [morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks [module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent +[sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely +[ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection diff --git a/examples/http/client.js b/examples/http/client.js index 31370a2..c1473a5 100644 --- a/examples/http/client.js +++ b/examples/http/client.js @@ -5,7 +5,7 @@ const mainView = require('./views/main') const app = choo() app.model(require('./models/error')) -app.model('api', require('./models/api')) +app.model(require('./models/api')) app.router((route) => [ route('/', mainView) diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 37907e3..65be0db 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -1,45 +1,31 @@ const http = require('../../../http') module.exports = { + namespace: 'api', state: { title: 'Button pushing machine 3000' }, reducers: { - set: function (action, state) { - return { 'api:title': action.payload } - } + set: (action, state) => ({ 'title': action.payload }) }, effects: { - good: performGoodRequest, - bad: performBadRequest + good: (action, state, send) => request('/good', send), + bad: (action, state, send) => request('/bad', send) } } -function performGoodRequest (action, state, send) { - http('/good', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) - if (res.statusCode !== 200) { - return send('error', { payload: body.payload }) - } - if (!body) { - return send('error', { payload: 'fatal: no body received' }) - } - send('api:set', { payload: body.message }) - }) -} - -function performBadRequest (action, state, send) { - http('/bad', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) +function request (uri, send, state) { + http(uri, { json: true }, function (err, res, body) { + if (err) return send('app:error', { payload: 'HTTP error' }) if (res.statusCode !== 200) { const message = (body && body.message) - ? body.message - : 'unknown server error' - return send('error', { payload: message }) + ? body.message + : 'unknown server error' + return send('app:error', { payload: message }) } if (!body) { - return send('error', { payload: 'fatal: no body received' }) + return send('app:error', { payload: 'fatal: no body received' }) } - send('api:set', { payload: body.title }) + send('api:set', { payload: body.message || body.title }) }) } diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 4bc3694..632d0e5 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -8,6 +8,7 @@ const ERROR_TIMEOUT = 1000 module.exports = { + namespace: 'app', state: { error: [], errorTimeDone: null, @@ -35,7 +36,7 @@ module.exports = { error: function (action, state, send) { const timeout = state.errorTimeDone - Date.now() setTimeout(function () { - send('error:delete') + send('app:error:delete') }, timeout) } } diff --git a/examples/http/server.js b/examples/http/server.js index c98ca55..b0da21e 100644 --- a/examples/http/server.js +++ b/examples/http/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) var index = 0 const errors = [ diff --git a/examples/http/views/main.js b/examples/http/views/main.js index 22c5414..4d0d435 100644 --- a/examples/http/views/main.js +++ b/examples/http/views/main.js @@ -1,10 +1,12 @@ const choo = require('../../../') module.exports = function (params, state, send) { + const error = state.app.error[0] + const title = state.api.title return choo.view`
-

${state['api:title']}

-

Latest error: ${state.error[0]}

+

${title}

+

Latest error: ${error}

diff --git a/examples/mailbox/client.js b/examples/mailbox/client.js index f2c1f5e..e71b15c 100644 --- a/examples/mailbox/client.js +++ b/examples/mailbox/client.js @@ -6,9 +6,9 @@ sf('tachyons') const app = choo() -app.model('inbox', require('./models/inbox')) -app.model('spam', require('./models/spam')) -app.model('sent', require('./models/sent')) +app.model(require('./models/inbox')) +app.model(require('./models/spam')) +app.model(require('./models/sent')) app.router((route) => [ route('/', require('./views/empty')), diff --git a/examples/mailbox/elements/email-list.js b/examples/mailbox/elements/email-list.js index e80a6f1..4a03b5e 100644 --- a/examples/mailbox/elements/email-list.js +++ b/examples/mailbox/elements/email-list.js @@ -3,7 +3,7 @@ const choo = require('../../../') module.exports = function (params, state, send) { const mailbox = params.mailbox - const messages = state[mailbox + ':messages'] + const messages = state[mailbox].messages return choo.view`
diff --git a/examples/mailbox/elements/email.js b/examples/mailbox/elements/email.js index 361f37b..04d9815 100644 --- a/examples/mailbox/elements/email.js +++ b/examples/mailbox/elements/email.js @@ -4,7 +4,7 @@ module.exports = function (params, state, send) { const mailbox = params.mailbox const message = params.message - const email = state[mailbox + ':messages'].filter(function (msg) { + const email = state[mailbox].messages.filter(function (msg) { return String(msg.id) === message })[0] diff --git a/examples/mailbox/elements/mailbox.js b/examples/mailbox/elements/mailbox.js index e435b2e..23a8f0e 100644 --- a/examples/mailbox/elements/mailbox.js +++ b/examples/mailbox/elements/mailbox.js @@ -5,9 +5,10 @@ module.exports = function () { return function (params, state, send) { const mailbox = params.mailbox const message = params.message + const messages = state[mailbox].messages if (message) { - const email = state[mailbox + ':messages'].filter(function (msg) { + const email = state[mailbox].messages.filter(function (msg) { return String(msg.id) === message })[0] @@ -15,7 +16,7 @@ module.exports = function () {
${createHeader()} - ${state[mailbox + ':messages'].map(function (msg) { + ${messages.map(function (msg) { return createMessage(msg, mailbox) })}
@@ -28,7 +29,7 @@ module.exports = function () { return choo.view`
${createHeader()} - ${state[mailbox + ':messages'].map(function (msg) { + ${messages.map(function (msg) { return createMessage(msg, mailbox) })}
diff --git a/examples/mailbox/elements/nav.js b/examples/mailbox/elements/nav.js index 2d51362..249ae80 100644 --- a/examples/mailbox/elements/nav.js +++ b/examples/mailbox/elements/nav.js @@ -3,16 +3,15 @@ const choo = require('../../../') const mailboxes = [ 'inbox', 'spam', 'sent' ] module.exports = function (params, state, send) { - const mailbox = params.mailbox - return choo.view` diff --git a/examples/mailbox/elements/pathname.js b/examples/mailbox/elements/pathname.js index d9eb157..7006900 100644 --- a/examples/mailbox/elements/pathname.js +++ b/examples/mailbox/elements/pathname.js @@ -2,9 +2,10 @@ const pathname = require('pathname-match') const choo = require('../../../') module.exports = function (params, state, send) { + const location = state.app.location return choo.view` - URL: ${pathname(state.location) || '/'} + URL: ${pathname(location) || '/'} ` } diff --git a/examples/mailbox/models/inbox.js b/examples/mailbox/models/inbox.js index c055a8c..440d852 100644 --- a/examples/mailbox/models/inbox.js +++ b/examples/mailbox/models/inbox.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'inbox', state: { messages: [ { diff --git a/examples/mailbox/models/sent.js b/examples/mailbox/models/sent.js index 0dc4a60..72acae0 100644 --- a/examples/mailbox/models/sent.js +++ b/examples/mailbox/models/sent.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'sent', state: { messages: [ { diff --git a/examples/mailbox/models/spam.js b/examples/mailbox/models/spam.js index 451e74d..754ff55 100644 --- a/examples/mailbox/models/spam.js +++ b/examples/mailbox/models/spam.js @@ -1,4 +1,5 @@ module.exports = { + namespace: 'spam', state: { messages: [ { diff --git a/examples/mailbox/server.js b/examples/mailbox/server.js index 85d6517..ebf3755 100644 --- a/examples/mailbox/server.js +++ b/examples/mailbox/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) function createRouter () { const router = serverRouter('/404') diff --git a/examples/server/client.js b/examples/server-rendering/client.js similarity index 100% rename from examples/server/client.js rename to examples/server-rendering/client.js diff --git a/examples/server/package.json b/examples/server-rendering/package.json similarity index 100% rename from examples/server/package.json rename to examples/server-rendering/package.json diff --git a/examples/server-rendering/server.js b/examples/server-rendering/server.js new file mode 100644 index 0000000..93e138f --- /dev/null +++ b/examples/server-rendering/server.js @@ -0,0 +1,34 @@ +const serverRouter = require('server-router') +const http = require('http') + +const PORT = 8080 +const client = require('./client') + +// If an incoming request accepts "text/html", render the +// appropriate HTML. Else use the API server +const apiRouter = createRouter() +const server = http.createServer(function (req, res) { + if (/text\/html/.test(req.headers.accept)) handleHtml(req, res) + else apiRouter(req, res) +}) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) + +// create a new router +// null -> fn +function createRouter () { + const apiRouter = serverRouter('/404') + apiRouter.on('/404', (req, res) => res.end('not found')) + apiRouter.on('/', (req, res) => { + res.end(JSON.stringify({ routes: [ '/', '/404' ] })) + }) + return apiRouter +} + +// render the client to string +// based on the requested url +// (obj, obj) -> null +function handleHtml (req, res) { + const html = client.toString(req.url, { message: 'hello server!' }) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(html) +} diff --git a/examples/server/views/main.js b/examples/server-rendering/views/main.js similarity index 100% rename from examples/server/views/main.js rename to examples/server-rendering/views/main.js diff --git a/examples/server/server.js b/examples/server/server.js deleted file mode 100644 index 051d06c..0000000 --- a/examples/server/server.js +++ /dev/null @@ -1,22 +0,0 @@ -const serverRouter = require('server-router') -const http = require('http') - -const PORT = 8080 - -const client = require('./client') - -const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) - -function createRouter () { - const router = serverRouter('/404') - - router.on('/404', (req, res) => res.end('not found')) - router.on('/', function (req, res, params) { - const html = client.toString('/', { message: 'hello server!' }) - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.end(html) - }) - - return router -} diff --git a/examples/title/client.js b/examples/title/client.js index d1d2274..7b6a348 100644 --- a/examples/title/client.js +++ b/examples/title/client.js @@ -1,26 +1,31 @@ -const choo = require('../') +const choo = require('../../') const app = choo() -app.model('title', { - state: { title: 'my-demo-app' }, +app.model({ + namespace: 'input', + state: { + title: 'my demo app' + }, reducers: { - 'update': (action, state) => ({ title: action.payload }) + update: (action, state) => ({ title: action.payload }) }, effects: { - 'update': (action, state, send) => (document.title = action.payload) + update: (action, state, send) => (document.title = action.payload) } }) -const mainView = (params, state, send) => choo.view` -
-

${state.title}

- - send('title:update', { payload: e.target.value })}> -
-` +const mainView = (params, state, send) => { + return choo.view` +
+

${state.input.title}

+ + send('input:update', { payload: e.target.value })}> +
+ ` +} app.router((route) => [ route('/', mainView) diff --git a/index.js b/index.js index 2a7d956..b054b7e 100644 --- a/index.js +++ b/index.js @@ -3,17 +3,16 @@ const sheetRouter = require('sheet-router') const document = require('global/document') const href = require('sheet-router/href') const sendAction = require('send-action') +const assert = require('assert') const xtend = require('xtend') const yo = require('yo-yo') choo.view = yo module.exports = choo -// A framework for creating sturdy web applications +// framework for creating sturdy web applications // null -> fn -function choo (opts) { - opts = opts || {} - const name = opts.name || 'choo' +function choo () { const _models = [] var _router = null @@ -25,6 +24,7 @@ function choo (opts) { return start // render the application to a string + // (str, obj) -> str function toString (route, state) { const initialState = {} @@ -40,71 +40,102 @@ function choo (opts) { } // start the application - // null -> DOMNode - function start () { + // obj -> DOMNode + function start (opts) { + opts = opts || {} + const name = opts.name || 'choo' const initialState = {} const reducers = {} const effects = {} _models.push(appInit(opts)) _models.forEach(function (model) { - if (model.state) apply(model.name, model.state, initialState) - if (model.reducers) apply(model.name, model.reducers, reducers) - if (model.effects) apply(model.name, model.effects, effects) + if (model.state) apply(model.namespace, model.state, initialState) + if (model.reducers) apply(model.namespace, model.reducers, reducers) + if (model.effects) apply(model.namespace, model.effects, effects) }) + // send() is used to trigger actions inside + // views, effects and subscriptions const send = sendAction({ onaction: handleAction, onchange: onchange, state: initialState }) + // subscriptions are loaded after sendAction() is called + // because they both need access to send() and can't + // react to actions (read-only) _models.forEach(function (model) { if (model.subscriptions) { + assert.ok(Array.isArray(model.subscriptions, 'subs must be an array')) model.subscriptions.forEach(function (sub) { sub(send) }) } }) + // the rootId is determined to find the application root + // on update. Since the DOM nodes change between updates, + // we must call document.querySelector() to find the root. + // Use different names when loading multiple choo applications + // on the same page const rootId = name + '-root' - const tree = _router(send.state().location, send.state(), send) + const tree = _router(send.state().app.location, send.state(), send) tree.setAttribute('id', rootId) return tree + // handle an action by either reducers, effects + // or both - return the new state when done + // (obj, obj, fn) -> obj function handleAction (action, state, send) { - var _reducers = false - var _effects = false - var newState = null + var reducersCalled = false + var effectsCalled = false - if (reducers[action.type]) { - newState = xtend(state, reducers[action.type](action, state)) - _reducers = true + // validate if a namespace exists. Namespaces + // are delimited by the first ':'. Perhaps + // we'll allow recursive namespaces in the + // future - who knows + if (/:/.test(action.type)) { + const arr = action.type.split(':') + var ns = arr.shift() + action.type = arr.join(':') } - if (effects[action.type]) { - effects[action.type](action, newState || state, send) - newState = newState || state - _effects = true + const _reducers = ns ? reducers[ns] : reducers + if (_reducers && _reducers[action.type]) { + if (ns) { + const newState = _reducers[action.type](action, state[ns]) + state[ns] = xtend(state[ns], newState) + } else state = xtend(state, reducers[action.type](action, state)) + reducersCalled = true } - if (!_reducers && !_effects) { + const _effects = ns ? effects[ns] : effects + if (_effects && _effects[action.type]) { + if (ns) _effects[action.type](action, state[ns], send) + else _effects[action.type](action, state, send) + effectsCalled = true + } + + if (!reducersCalled && !effectsCalled) { throw new Error('Could not find action ' + action.type) } - return newState + return state } - // update on every change + // update the DOM after every state mutation + // (obj, obj) -> null function onchange (action, state) { const oldTree = document.querySelector('#' + rootId) - const newTree = _router(state.location, state, send) + const newTree = _router(state.app.location, state, send) newTree.setAttribute('id', rootId) yo.update(oldTree, newTree) } } - // register all routes + // register all routes on the router // [obj|fn] -> null function router (cb) { _router = sheetRouter(cb) @@ -113,9 +144,7 @@ function choo (opts) { // create a new model // (str?, obj) -> null - function model (name, model) { - if (!model) model = name - if (typeof name === 'string') model.name = name + function model (model) { _models.push(model) } } @@ -124,45 +153,47 @@ function choo (opts) { // obj -> obj function appInit (opts) { const model = { - state: { - location: document.location.href - }, + namespace: 'app', + state: { location: document.location.href }, + subscriptions: [], reducers: { - location: setLocation - }, - subscriptions: [] + // handle href links + location: function setLocation (action, state) { + return { + location: action.location.replace(/#.*/, '') + } + } + } } - if (opts.href !== false) { - model.subscriptions.push(function (send) { - href(function (href) { - send('location', { location: href }) - }) - }) - } - - if (opts.history !== false) { - model.subscriptions.push(function (send) { - history(function (href) { - send('location', { location: href }) - }) - }) - } + // enable catching links + // enable HTML5 history API + if (opts.history !== false) pushLocationSub(href) + if (opts.history !== false) pushLocationSub(history) return model - // handle href links - function setLocation (action, state) { - const location = action.location.replace(/#.*/, '') - return xtend(state, { location: location }) + // create a new subscription that modifies + // 'app:location' and push it to be loaded + // fn -> null + function pushLocationSub (cb) { + model.subscriptions.push(function (send) { + cb(function (href) { + send('app:location', { location: href }) + }) + }) } } // compose an object conditionally +// optionally contains a namespace +// which is used to nest properties. // (str, obj, obj) -> null -function apply (name, source, target) { +function apply (ns, source, target) { Object.keys(source).forEach(function (key) { - if (name) target[name + ':' + key] = source[key] - else target[key] = source[key] + if (ns) { + if (!target[ns]) target[ns] = {} + target[ns][key] = source[key] + } else target[key] = source[key] }) } diff --git a/site/server.js b/site/server.js index 85d6517..ebf3755 100644 --- a/site/server.js +++ b/site/server.js @@ -6,7 +6,7 @@ const http = require('http') const PORT = 8080 const server = http.createServer(createRouter()) -server.listen(PORT, () => console.log(`listening on port ${PORT}`)) +server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`)) function createRouter () { const router = serverRouter('/404')