├── .gitignore ├── example ├── store.js ├── app.js └── counter.choo ├── .travis.yml ├── .editorconfig ├── test ├── fixtures │ ├── basic.choo │ ├── package.json │ ├── basic.js │ ├── local.js │ └── local.choo └── test.js ├── package.json ├── license ├── index.js └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/temp 3 | *.log 4 | test/fixtures/node_modules/ -------------------------------------------------------------------------------- /example/store.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | state: { 3 | count: 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'stable' 4 | - '5' 5 | - '4' 6 | before_script: 7 | - 'cd test/fixtures && npm install && cd ../..' 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /test/fixtures/basic.choo: -------------------------------------------------------------------------------- 1 | /* choo-view */ 2 | 3 | 4 |
5 |

${state.name}

6 | ${this.author} 7 |
8 | 9 | /* choo-model */ 10 | { 11 | namespace: 'ignored', 12 | local: { author: 'Peter' }, 13 | state: { name: 'Jota', undef: true } 14 | } 15 | -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const component = require('./counter.choo')({ author: 'Yerko' }) 3 | 4 | const app = choo() 5 | app.model(component.model) 6 | 7 | app.router((route) => [ 8 | route('/', component.view) 9 | ]) 10 | 11 | const tree = app.start() 12 | document.body.appendChild(tree) 13 | -------------------------------------------------------------------------------- /test/fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixtures", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "basic.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "bel": "^4.5.0", 13 | "choo": "^3.3.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example/counter.choo: -------------------------------------------------------------------------------- 1 | /* choo-view */ 2 | 3 | `
4 |

${author}

5 |

${state.count}

6 | 7 |
` 8 | 9 | /* choo-model */ 10 | { 11 | local: { 12 | author: '' 13 | }, 14 | effects: { 15 | increment: (action, state) => ({ count: state.count + 1 }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/basic.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const basicComponent = require('./basic.choo') 3 | 4 | const app = choo() 5 | 6 | app.model({ 7 | state: { name: 'John Doe' } 8 | }) 9 | 10 | app.model(basicComponent.model) 11 | 12 | app.router(route => [ 13 | route('/', basicComponent.view) 14 | ]) 15 | 16 | // export app for tests 17 | module.exports = app 18 | -------------------------------------------------------------------------------- /test/fixtures/local.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const basicComponent = require('./local.choo') 3 | 4 | basicComponent.local.author = 'Me' 5 | const app = choo() 6 | 7 | app.model({ 8 | state: { username: 'John Doe', mail: 'john@doe.com' } 9 | }) 10 | 11 | app.model(basicComponent.model) 12 | 13 | app.router(route => [ 14 | route('/', basicComponent.view) 15 | ]) 16 | 17 | // export app for tests 18 | module.exports = app 19 | -------------------------------------------------------------------------------- /test/fixtures/local.choo: -------------------------------------------------------------------------------- 1 | /* choo-view */ 2 | 3 |
4 |

${state.username} <${state.mail}>

5 |

${this.author}, You are now ${this.active ? 'active' : 'not active'}

6 | 7 |
8 | 9 | /* choo-model */ 10 | 11 | { 12 | namespace: 'input', 13 | state: { 14 | title: 'my demo app' 15 | }, 16 | local: { 17 | author: '', 18 | active: true 19 | }, 20 | reducers: { 21 | update: (data, state) => ({ title: data.payload }) 22 | }, 23 | effects: { 24 | edit: (data, state, send, done) => { 25 | document.title = data.payload 26 | done() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chooify", 3 | "description": "Browserify transform to process .choo files as isolated, stateless choo components", 4 | "author": "YerkoPalma", 5 | "version": "0.6.0", 6 | "main": "index.js", 7 | "files": [ 8 | "index.js" 9 | ], 10 | "standard": { 11 | "ignore": [ 12 | "test/temp/**", 13 | "test/fixtures/*.choo" 14 | ] 15 | }, 16 | "scripts": { 17 | "test": "standard --verbose | snazzy && mocha test/test.js --slow=5000 --timeout=10000" 18 | }, 19 | "repository": "YerkoPalma/chooify", 20 | "keywords": [], 21 | "license": "MIT", 22 | "dependencies": { 23 | "falafel": "^2.0.0", 24 | "json5": "^0.5.0", 25 | "through": "^2.3.8" 26 | }, 27 | "devDependencies": { 28 | "browserify": "^13.1.0", 29 | "budo": "^9.2.1", 30 | "chai": "^3.5.0", 31 | "jsdom": "^9.6.0", 32 | "mkdirp": "^0.5.1", 33 | "mocha": "^3.1.0", 34 | "rimraf": "^2.5.4", 35 | "snazzy": "^5.0.0", 36 | "standard": "^8.3.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 YerkoPalma 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | const browserify = require('browserify') 3 | const chooify = require('../index') 4 | const fs = require('fs') 5 | const path = require('path') 6 | const expect = require('chai').expect 7 | const rimraf = require('rimraf') 8 | const mkdirp = require('mkdirp') 9 | const jsdom = require('jsdom') 10 | 11 | const tempDir = path.resolve(__dirname, './temp') 12 | const mockEntry = path.resolve(tempDir, 'entry.js') 13 | rimraf.sync(tempDir) 14 | mkdirp.sync(tempDir) 15 | 16 | function test (file, assert) { 17 | it(file, done => { 18 | fs.writeFileSync(mockEntry, ` 19 | window.chooModule = require('../fixtures/${file}.choo') 20 | window.chooApp = require('../fixtures/${file}.js') 21 | window.document.body.appendChild(window.chooApp.start()) 22 | `) 23 | browserify(mockEntry) 24 | .transform(chooify) 25 | .bundle((err, buf) => { 26 | if (err) { 27 | console.error(err.stack.replace(/^.*?\n/, '')) 28 | return done(err) 29 | } 30 | jsdom.env({ 31 | html: '', 32 | src: [buf.toString()], 33 | done: (err, window) => { 34 | if (err) { 35 | console.error(err.stack.replace(/^.*?\n/, '')) 36 | return done(err) 37 | } 38 | assert(window) 39 | done() 40 | } 41 | }) 42 | }) 43 | }) 44 | } 45 | 46 | describe('chooify', () => { 47 | test('basic', window => { 48 | const module = window.chooModule 49 | const app = window.chooApp 50 | expect(module.view).to.be.ok 51 | expect(module.model).to.be.ok 52 | 53 | const h1 = window.document.querySelector('h1').innerHTML 54 | expect(h1).to.equal('John Doe') 55 | expect(app._store.state().undef).to.be.not.ok 56 | const span = window.document.querySelector('span').innerHTML 57 | expect(span).to.equal('Peter') 58 | }) 59 | 60 | test('local', window => { 61 | const module = window.chooModule 62 | expect(module.view).to.be.ok 63 | expect(module.model).to.be.ok 64 | 65 | // should have local state correctly setted 66 | const p = window.document.querySelector('p').textContent 67 | expect(p).to.equal('Me, You are now active') 68 | // should change state and render properly when effect is trigered 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const through = require('through') 3 | const JSON5 = require('json5') 4 | const falafel = require('falafel') 5 | 6 | module.exports = function chooify (file) { 7 | // ignore files without choo extension 8 | if (!/.choo$/.test(file)) { 9 | return through() 10 | } 11 | let data = '' 12 | 13 | let stream = through(write, end) 14 | 15 | function write (buf) { 16 | data += buf 17 | } 18 | 19 | function end () { 20 | const viewRegex = /(\/\* choo\-view \*\/)[\s\S]*(\/\* choo\-model \*\/)|(\/\* choo\-view \*\/)[\s\S]*/ 21 | const modelRegex = /(\/\* choo\-model \*\/)[\s\S]*(\/\* choo\-view \*\/)|(\/\* choo\-model \*\/)[\s\S]*/ 22 | 23 | // get the model part 24 | const parsedModel = parseModel(data.match(modelRegex)[0]) 25 | // get the view part, ensure to pass the model object string (it has the local property) 26 | const view = parseView(data.match(viewRegex)[0], parsedModel.local) 27 | // initialize local data only if it is present in the local property of the model 28 | const init = ` 29 | function chooify () {} 30 | chooify.local = ${JSON5.stringify(parsedModel.local)} 31 | chooify.view = ${view} 32 | chooify.model = ${parsedModel.model} 33 | ` 34 | 35 | stream.queue(` 36 | ${init} 37 | module.exports = chooify`) 38 | stream.queue(null) 39 | } 40 | return stream 41 | } 42 | 43 | function clean (str) { 44 | str = str.replace('/* choo-view */', '') 45 | str = str.replace('/* choo-model */', '') 46 | return str.trim() 47 | } 48 | 49 | function parseModel (model) { 50 | let str = 'var o = ' + clean(model) 51 | 52 | let local 53 | let output = falafel(str, (node) => { 54 | // parse arrow functions 55 | if (/ArrowFunctionExpression/.test(node.type)) { 56 | const params = node.params.map(param => param.name).join(', ') 57 | // inline arrow function 58 | let body 59 | if (/ObjectExpression/.test(node.body.type)) body = `{ return ${node.body.source()} }` 60 | else body = '\n ' + node.body.source() 61 | node.update(`function (${params}) ${body}`) 62 | } 63 | // exclude (ignore) state 64 | if (/Property/.test(node.type) && node.key.name === 'state') node.value.update('{}') 65 | // ignore namespace 66 | if (/Property/.test(node.type) && node.key.name === 'namespace') node.value.update('undefined') 67 | if (/Property/.test(node.type) && node.key.name === 'local') { 68 | local = JSON5.parse(node.value.source()) 69 | } 70 | }) 71 | output = falafel(output.toString(), node => { 72 | // assign local value to this in effects 73 | if (/Property/.test(node.type) && ['effects', 'reducers', 'subscriptions'].indexOf(node.key.name) > -1) { 74 | node.value.properties.forEach(function (effect) { 75 | effect.value.update('(' + effect.value.source() + ').bind(chooify.local)') 76 | }) 77 | } 78 | }) 79 | let outputStr = output.toString() 80 | // check if local property is defined 81 | // let local = output.local 82 | // check if effects, reducers and/or subscriptions are arrow functions 83 | // if they are, replace them by old fashioned functions 84 | // bind model.local to this in effects, reducers and subscriptions 85 | return { model: outputStr.substring(8, outputStr.length), local } 86 | } 87 | 88 | function parseView (view, local) { 89 | // wrap in a function that require choo/html 90 | // check if there is any package required 91 | // require the package after choo/html and before the view function 92 | return `(function () { 93 | const html = require('bel') 94 | 95 | return (function (state, prev, send) { 96 | return html\`${clean(view)}\` 97 | }).bind(chooify.local) 98 | })()` 99 | } 100 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # chooify [![Build Status](https://secure.travis-ci.org/YerkoPalma/chooify.svg?branch=master)](https://travis-ci.org/YerkoPalma/chooify) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 2 | 3 | > WIP - Browserify transform to process .choo files as isolated, stateless choo components 4 | 5 | ## TODO 6 | 7 | - [ ] `require` .choo files. 8 | - [ ] Parse view. 9 | - [x] Wrap view function to bind local data to `this`. 10 | - [ ] Allow to require packages. 11 | - [x] Parse and export model. 12 | - [x] Ignore state. 13 | - [x] Remove namespaces. 14 | - [x] Read local data and pass it to view. 15 | - [x] Bind local data to this in effects. 16 | - [x] Bind local data to this in reducers. 17 | - [x] Bind local data to this in subscriptions. 18 | - [x] Allow to pass local data when required. 19 | - [ ] Create init hook. 20 | - [ ] Wrap initial state. 21 | - [ ] Re-render when there are local changes. 22 | - [ ] Make a _real life_ example. 23 | 24 | ## Installation 25 | 26 | ```bash 27 | npm install --save chooify 28 | ``` 29 | 30 | ## Usage 31 | 32 | Run it as any Browserify transform 33 | 34 | ### CLI 35 | 36 | ```bash 37 | $ browserify -e index.js -o bundle.js -t chooify 38 | ``` 39 | 40 | ### Node 41 | 42 | ```javascript 43 | var fs = require("fs") 44 | var browserify = require('browserify') 45 | var chooify = require('chooify') 46 | 47 | browserify('./main.js') 48 | .transform(chooify) 49 | .bundle() 50 | .pipe(fs.createWriteStream("bundle.js")) 51 | ``` 52 | 53 | ## How it works 54 | 55 | The above transform allows you to write choo stateless components like this 56 | 57 | ```javascript 58 | // my-component.choo -> yep, .choo files 59 | 60 | /* choo-view */ 61 | 62 |
send('toggle')}> 63 |

${state.title}

64 |
65 | 66 | /* choo-model */ 67 | { 68 | local: { 69 | active: false 70 | }, 71 | effects: { 72 | toggle: (data, state, send, done) => { 73 | this.active = !this.active 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | ```javascript 80 | // main.js 81 | const choo = require('choo') 82 | const init = require('chooify/init') 83 | const state = require('./state') 84 | const mainComponent = require('./components/main.choo')({ active: true }) 85 | 86 | const app = choo() 87 | init(app, state) 88 | 89 | app.model(mainComponent.model) 90 | 91 | app.router(route => [ 92 | route('/', mainComponent.view) 93 | ]) 94 | ``` 95 | 96 | ## Explanation 97 | 98 | Tha main idea is to have components that manage local data without polluting the global state, and that are completly reusable. 99 | To do this, I created the `.choo` files, which is a javascript file with two sections, the view and the model, which are also exported, when required, as an object with two properties, view and model. 100 | When required, the choo components receive an object as input, and return an object with the view and model, for choo app. 101 | The input object, is the local data for that component. 102 | 103 | ### The model section 104 | 105 | Starts whit a comment like this `/* choo-model */` and ends with the end of the file or the start of the view section. It must contain an object definition, which is a choo model, with some litle differences. 106 | 107 | - No `namespace` support. 108 | - `state` property is ignored. 109 | - `local` property added. The component local data, not binded to the global app state, can be initialized here and/or by passing it when required. IF initialized in both places, like in the example, the input data takes preference. Anything passed that is not defined in the model part will be ignored. 110 | - Local data binded to `this` in effects, reducers and subscriptions 111 | 112 | ### The view section 113 | 114 | Starts whit a comment like this `/* choo-view */` and ends with the end of the file or the start of the model section. It must contain a string to be parsed by bel. 115 | You can access to the `state, prev, send` arguments, as any other choo view, and to any data defined in the component local property of the model, as the `active` local data in the example. 116 | 117 | ### Considerations 118 | 119 | Components ignore the `state` property of choo model, so to initialize a global state, this module also expose an `init` method that implement the `wrapInitialState` hook, so your global state is in a single place and not splitted in different models. 120 | There is no required section for components. You can have a choo file with no model section or without view section, also you could use a component view outside of the router inside another component. You can even pass a full component as local data of another component (maybe we could have an `extend` property in the model section?). 121 | Effects and reducers are still global, to make them local, we must define a way of communication between components. 122 | 123 | ## License 124 | 125 | MIT 126 | 127 | Crafted with <3 by [Yerko Palma](https://github.com/YerkoPalma). 128 | 129 | *** 130 | 131 | > This package was initially generated with [yeoman](http://yeoman.io) and the [p generator](https://github.com/johnotander/generator-p.git). 132 | --------------------------------------------------------------------------------