diff --git a/.gitignore b/.gitignore index 7c95f0e9..6a35fb39 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage.json .zuulrc package-lock.json yarn.lock +.idea diff --git a/.travis.yml b/.travis.yml index ec71b5b5..825071b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ node_js: - '6' -- '7' - '8' +- '10' sudo: false language: node_js env: @@ -21,5 +21,10 @@ addons: - ubuntu-toolchain-r-test packages: - g++-4.8 + - xvfb +install: + - export DISPLAY=':99.0' + - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + - npm install script: npm run test # after_script: npm i -g codecov.io && cat ./coverage/lcov.info | codecov diff --git a/README.md b/README.md index aa84781a..4b5855e5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

choo

+

Choo

:steam_locomotive::train::train::train::train::train: @@ -35,8 +35,8 @@ - Downloads + Download @@ -85,7 +85,7 @@
-## Table of Content +## Table of Contents - [Features](#features) - [Example](#example) - [Philosophy](#philosophy) @@ -93,6 +93,7 @@ - [State](#state) - [Routing](#routing) - [Server Rendering](#server-rendering) +- [Components](#components) - [Optimizations](#optimizations) - [FAQ](#faq) - [API](#api) @@ -101,7 +102,7 @@ - [Support](#support) ## Features -- __minimal size:__ weighing `4kb`, `choo` is a tiny little framework +- __minimal size:__ weighing `4kb`, Choo is a tiny little framework - __event based:__ our performant event system makes writing apps easy - __small api:__ with only 6 methods there's not much to learn - __minimal tooling:__ built for the cutting edge `browserify` compiler @@ -111,11 +112,11 @@ ## Example ```js var html = require('choo/html') -var log = require('choo-log') +var devtools = require('choo-devtools') var choo = require('choo') var app = choo() -app.use(log()) +app.use(devtools()) app.use(countStore) app.route('/', mainView) app.mount('body') @@ -153,7 +154,7 @@ _Real casually._ We believe frameworks should be disposable, and components recyclable. We don't want a web where walled gardens jealously compete with one another. By making the DOM the lowest common denominator, switching from one framework to another -becomes frictionless. `choo` is modest in its design; we don't believe it will +becomes frictionless. Choo is modest in its design; we don't believe it will be top of the class forever, so we've made it as easy to toss out as it is to pick up. @@ -169,7 +170,7 @@ At the core of Choo is an event emitter, which is used for both application logic but also to interface with the framework itself. The package we use for this is [nanobus](https://github.com/choojs/nanobus). -You can access the emitter through `app.use(state, emitter)`, `app.route(route, +You can access the emitter through `app.use(state, emitter, app)`, `app.route(route, view(state, emit))` or `app.emitter`. Routes only have access to the `emitter.emit` method to encourage people to separate business logic from render logic. @@ -208,10 +209,13 @@ and `'render'`. Similar to [history.replaceState](http://devdocs.io/dom/history#history-replacestate). ### `'popState'`|`state.events.POPSTATE` -This event should be emitted to navigate to a previous route. The new route -will be a previous entry in the browser's history stack, and will emit -`'navigate'` and `'render'`. Similar to -[history.popState](http://devdocs.io/dom_events/popstate). +This event is emitted when the user hits the 'back' button in their browser. +The new route will be a previous entry in the browser's history stack, and +immediately afterward the`'navigate'` and `'render'`events will be emitted. +Similar to [history.popState](http://devdocs.io/dom_events/popstate). (Note +that `emit('popState')` will _not_ cause a popState action - use +`history.go(-1)` for that - this is different from the behaviour of `pushState` +and `replaceState`!) ### `'DOMTitleChange'`|`state.events.DOMTITLECHANGE` This event should be emitted whenever the `document.title` needs to be updated. @@ -225,6 +229,10 @@ Choo comes with a shared state object. This object can be mutated freely, and is passed into the view functions whenever `'render'` is emitted. The state object comes with a few properties set. +When initializing the application, `window.initialState` is used to provision +the initial state. This is especially useful when combined with server +rendering. See [server rendering](#server-rendering) for more details. + ### `state.events` A mapping of Choo's built in events. It's recommended to extend this object with your application's events. By defining your event names once and setting @@ -241,7 +249,7 @@ An object containing the current queryString. `/foo?bin=baz` becomes `{ bin: 'baz' }`. ### `state.href` -An object containing the current href. `/foo?bin=baz` becomes `foo`. +An object containing the current href. `/foo?bin=baz` becomes `/foo`. ### `state.route` The current name of the route used in the router (e.g. `/foo/:bar`). @@ -249,12 +257,19 @@ The current name of the route used in the router (e.g. `/foo/:bar`). ### `state.title` The current page title. Can be set using the `DOMTitleChange` event. +### `state.components` +An object _recommended_ to use for local component state. + +### `state.cache(Component, id, [...args])` +Generic class cache. Will lookup Component instance by id and create one if not +found. Useful for working with stateful [components](#components). + ## Routing Choo is an application level framework. This means that it takes care of everything related to routing and pathnames for you. ### Params -Params can be registered by prepending the routename with `:routename`, e.g. +Params can be registered by prepending the route name with `:routename`, e.g. `/foo/:bar/:baz`. The value of the param will be saved on `state.params` (e.g. `state.params.bar`). Wildcard routes can be registered with `*`, e.g. `/foo/*`. The value of the wildcard will be saved under `state.params.wildcard`. @@ -269,10 +284,12 @@ Querystrings (e.g. `?foo=bar`) are ignored when matching routes. An object containing the key-value mappings exists as `state.query`. ### Hash routing -Using hashes to delimit routes is supported out of the box (e.g. `/foo#bar`). -When a hash is found we also check if there's an available anchor on the same -page, and will scroll the screen to the position. Using both hashes in URLs and -anchor links on the page is generally not recommended. +By default hashes are treated as part of the url when routing. Using hashes to +delimit routes (e.g. `/foo#bar`) can be disabled by setting the `hash` +[option](#app--chooopts) to `false`. Regardless, when a hash is found we also +check if there's an available anchor on the same page, and will scroll the +screen to the position. Using both hashes in URLs and anchor links on the page +is generally not recommended. ### Following links By default all clicks on `` tags are handled by the router through the @@ -291,12 +308,12 @@ constructor. The event is not handled under the following conditions: pages](https://mathiasbynens.github.io/rel-noopener/). ### Navigating programmatically -To can navigate routes you can emit `'pushState'`, `'popState'` or +To navigate routes you can emit `'pushState'`, `'popState'` or `'replaceState'`. See [#events](#events) for more details about these events. ## Server Rendering -Choo was built with Node in mind. To render on the server call `.toString()` on -your application. +Choo was built with Node in mind. To render on the server call +`.toString(route, [state])` on your `choo` instance. ```js var html = require('choo/html') @@ -314,6 +331,112 @@ console.log(string) // => '
Hello Node
' ``` +When starting an application in the browser, it's recommended to provide the +same `state` object available as `window.initialState`. When the application is +started, it'll be used to initialize the application state. The process of +server rendering, and providing an initial state on the client to create the +exact same document is also known as "rehydration". + +For security purposes, after `window.initialState` is used it is deleted from +the `window` object. + +```html + + + + + + + +``` + +## Components +From time to time there will arise a need to have an element in an application +hold a self-contained state or to not rerender when the application does. This +is common when using 3rd party libraries to e.g. display an interactive map or a +graph and you rely on this 3rd party library to handle modifications to the DOM. +Components come baked in to Choo for these kinds of situations. See +[nanocomponent][nanocomponent] for documentation on the component class. + +```javascript +// map.js +var html = require('choo/html') +var mapboxgl = require('mapbox-gl') +var Component = require('choo/component') + +module.exports = class Map extends Component { + constructor (id, state, emit) { + super(id) + this.local = state.components[id] = {} + } + + load (element) { + this.map = new mapboxgl.Map({ + container: element, + center: this.local.center + }) + } + + update (center) { + if (center.join() !== this.local.center.join()) { + this.map.setCenter(center) + } + return false + } + + createElement (center) { + this.local.center = center + return html`
` + } +} +``` + +```javascript +// index.js +var choo = require('choo') +var html = require('choo/html') +var Map = require('./map.js') + +var app = choo() +app.route('/', mainView) +app.mount('body') + +function mainView (state, emit) { + return html` + + + ${state.cache(Map, 'my-map').render(state.center)} + + ` + + function onclick () { + emit('locate') + } +} + +app.use(function (state, emitter) { + state.center = [18.0704503, 59.3244897] + emitter.on('locate', function () { + window.navigator.geolocation.getCurrentPosition(function (position) { + state.center = [position.coords.longitude, position.coords.latitude] + emitter.emit('render') + }) + }) +}) +``` + +### Caching components +When working with stateful components, one will need to keep track of component +instances – `state.cache` does just that. The component cache is a function +which takes a component class and a unique id (`string`) as it's first two +arguments. Any following arguments will be forwarded to the component constructor +together with `state` and `emit`. + +The default class cache is an LRU cache (using [nanolru][nanolru]), meaning it +will only hold on to a fixed amount of class instances (`100` by default) before +starting to evict the least-recently-used instances. This behavior can be +overriden with [options](#app--chooopts). + ## Optimizations Choo is reasonably fast out of the box. But sometimes you might hit a scenario where a particular part of the UI slows down the application, and you want to @@ -356,31 +479,30 @@ We use the `require('assert')` module from Node core to provide helpful error messages in development. In production you probably want to strip this using [unassertify][unassertify]. -To convert inlined HTML to valid DOM nodes we use `require('bel')`. This has +To convert inlined HTML to valid DOM nodes we use `require('nanohtml')`. This has overhead during runtime, so for production environments we should unwrap this -using [yo-yoify][yo-yoify]. +using the [nanohtml transform][nanohtml]. Setting up browserify transforms can sometimes be a bit of hassle; to make this -more convenient we recommend using [bankai][bankai] with `--optimize` to -compile your assets for production. +more convenient we recommend using [bankai build][bankai] to build your assets for production. ## FAQ -### Why is it called choo? +### 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. +non-scary. Choo embraces that. Also imagine telling some business people you chose to rewrite something critical for serious bizcorp using a train themed framework. :steam_locomotive::train::train::train: -### Is it called choo, choo.js or...? -It's called "choo", though we're fine if you call it "choo-choo" or -"chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if / +### Is it called Choo, Choo.js or...? +It's called "Choo", though we're fine if you call it "Choo-choo" or +"Chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if / when you shimmy like you're a locomotive. -### Does choo use a virtual-dom? -`choo` uses [nanomorph][nanomorph], which diffs real DOM nodes instead of +### Does Choo use a virtual-dom? +Choo uses [nanomorph][nanomorph], which diffs real DOM nodes instead of virtual nodes. It turns out that [browsers are actually ridiculously good at dealing with DOM nodes][morphdom-bench], and it has the added benefit of working with _any_ library that produces valid DOM nodes. So to put a long @@ -389,16 +511,16 @@ answer short: we're using something even better. ### How can I support older browsers? Template strings aren't supported in all browsers, and parsing them creates significant overhead. To optimize we recommend running `browserify` with -[yo-yoify][yo-yoify] as a global transform or using [bankai][bankai] directly. +[nanohtml][nanohtml] as a global transform or using [bankai][bankai] directly. ```sh -$ browserify -g yo-yoify +$ browserify -g nanohtml ``` ### Is choo production ready? Sure. ## API -This section provides documentation on how each function in `choo` works. It's +This section provides documentation on how each function in Choo works. It's intended to be a technical reference. If you're interested in learning choo for the first time, consider reading through the [handbook][handbook] first :sparkles: @@ -409,12 +531,18 @@ Initialize a new `choo` instance. `opts` can also contain the following values: history API. - __opts.href:__ default: `true`. Handle all relative `
` clicks and call `emit('render')` - -### `app.use(callback(state, emitter))` -Call a function and pass it a `state` and `emitter`. `emitter` is an instance +- __opts.cache:__ default: `undefined`. Override default class cache used by + `state.cache`. Can be a a `number` (maximum number of instances in cache, + default `100`) or an `object` with a [nanolru][nanolru]-compatible API. +- __opts.hash:__ default: `true`. Treat hashes in URLs as part of the pathname, + transforming `/foo#bar` to `/foo/bar`. This is useful if the application is + not mounted at the website root. + +### `app.use(callback(state, emitter, app))` +Call a function and pass it a `state`, `emitter` and `app`. `emitter` is an instance of [nanobus](https://github.com/choojs/nanobus/). You can listen to messages by calling `emitter.on()` and emit messages by calling -`emitter.emit()`. Callbacks passed to `app.use()` are commonly referred to as +`emitter.emit()`. `app` is the same Choo instance. Callbacks passed to `app.use()` are commonly referred to as `'stores'`. If the callback has a `.storeName` property on it, it will be used to identify @@ -430,10 +558,20 @@ hood. See [#routing](#routing) for an overview of how to use routing efficiently. ### `app.mount(selector)` -Start the application and mount it on the given `querySelector`. Uses -[nanomount][nanomount] under the hood. This will _replace_ the selector provided -with the tree returned from `app.start()`. If you want to add the app as a child -to an element, use `app.start()` to obtain the tree and manually append it. +Start the application and mount it on the given `querySelector`, +the given selector can be a String or a DOM element. + +In the browser, this will _replace_ the selector provided with the tree returned from `app.start()`. +If you want to add the app as a child to an element, use `app.start()` to obtain the tree and manually append it. + +On the server, this will save the `selector` on the app instance. +When doing server side rendering, you can then check the `app.selector` property to see where the render result should be inserted. + +Returns `this`, so you can easily export the application for server side rendering: + +```js +module.exports = app.mount('body') +``` ### `tree = app.start()` Start the application. Returns a tree of DOM nodes that can be mounted using @@ -444,11 +582,11 @@ Render the application to a string. Useful for rendering on the server. ### `choo/html` Create DOM nodes from template string literals. Exposes -[bel](https://github.com/shama/bel). Can be optimized using -[yo-yoify][yo-yoify]. +[nanohtml](https://github.com/choojs/nanohtml). Can be optimized using +[nanohtml][nanohtml]. ### `choo/html/raw` -Exposes [bel/raw](https://github.com/shama/bel#unescaping) helper for rendering raw HTML content. +Exposes [nanohtml/raw](https://github.com/shama/nanohtml#unescaping) helper for rendering raw HTML content. ## Installation ```sh @@ -459,8 +597,6 @@ $ npm install choo - [bankai](https://github.com/choojs/bankai) - streaming asset compiler - [stack.gl](http://stack.gl/) - open software ecosystem for WebGL - [yo-yo](https://github.com/maxogden/yo-yo) - tiny library for modular UI -- [bel](https://github.com/shama/bel) - composable DOM elements using template - strings - [tachyons](https://github.com/tachyons-css/tachyons) - functional CSS for humans - [sheetify](https://github.com/stackcss/sheetify) - modular CSS bundler for @@ -548,8 +684,10 @@ Become a backer, and buy us a coffee (or perhaps lunch?) every month or so. ## License [MIT](https://tldrlegal.com/license/mit-license) +[nanocomponent]: https://github.com/choojs/nanocomponent +[nanolru]: https://github.com/s3ththompson/nanolru [bankai]: https://github.com/choojs/bankai -[bel]: https://github.com/shama/bel +[nanohtml]: https://github.com/choojs/nanohtml [browserify]: https://github.com/substack/node-browserify [budo]: https://github.com/mattdesl/budo [es2020]: https://github.com/yoshuawuyts/es2020 @@ -558,8 +696,6 @@ Become a backer, and buy us a coffee (or perhaps lunch?) every month or so. [morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks [nanomorph]: https://github.com/choojs/nanomorph [nanorouter]: https://github.com/choojs/nanorouter -[nanomount]: https://github.com/yoshuawuyts/nanomount [yo-yo]: https://github.com/maxogden/yo-yo -[yo-yoify]: https://github.com/shama/yo-yoify [unassertify]: https://github.com/unassert-js/unassertify [window-performance]: https://developer.mozilla.org/en-US/docs/Web/API/Performance diff --git a/component/cache.js b/component/cache.js new file mode 100644 index 00000000..101acc0d --- /dev/null +++ b/component/cache.js @@ -0,0 +1,41 @@ +var assert = require('assert') +var LRU = require('nanolru') + +module.exports = ChooComponentCache + +function ChooComponentCache (state, emit, lru) { + assert.ok(this instanceof ChooComponentCache, 'ChooComponentCache should be created with `new`') + + assert.equal(typeof state, 'object', 'ChooComponentCache: state should be type object') + assert.equal(typeof emit, 'function', 'ChooComponentCache: emit should be type function') + + if (typeof lru === 'number') this.cache = new LRU(lru) + else this.cache = lru || new LRU(100) + this.state = state + this.emit = emit +} + +// Get & create component instances. +ChooComponentCache.prototype.render = function (Component, id) { + assert.equal(typeof Component, 'function', 'ChooComponentCache.render: Component should be type function') + assert.ok(typeof id === 'string' || typeof id === 'number', 'ChooComponentCache.render: id should be type string or type number') + + var el = this.cache.get(id) + if (!el) { + var args = [] + for (var i = 2, len = arguments.length; i < len; i++) { + args.push(arguments[i]) + } + args.unshift(Component, id, this.state, this.emit) + el = newCall.apply(newCall, args) + this.cache.set(id, el) + } + + return el +} + +// Because you can't call `new` and `.apply()` at the same time. This is a mad +// hack, but hey it works so we gonna go for it. Whoop. +function newCall (Cls) { + return new (Cls.bind.apply(Cls, arguments)) // eslint-disable-line +} diff --git a/component/index.js b/component/index.js new file mode 100644 index 00000000..b639a1bf --- /dev/null +++ b/component/index.js @@ -0,0 +1 @@ +module.exports = require('nanocomponent') diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 00000000..1a993212 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/example/components/footer/clear-button.js b/example/components/footer/clear-button.js new file mode 100644 index 00000000..2aaedf9b --- /dev/null +++ b/example/components/footer/clear-button.js @@ -0,0 +1,15 @@ +var html = require('nanohtml') + +module.exports = deleteCompleted + +function deleteCompleted (emit) { + return html` + + ` + + function deleteAllCompleted () { + emit('todos:deleteCompleted') + } +} diff --git a/example/components/footer/filter-button.js b/example/components/footer/filter-button.js new file mode 100644 index 00000000..ce6d500c --- /dev/null +++ b/example/components/footer/filter-button.js @@ -0,0 +1,18 @@ +var html = require('nanohtml') + +module.exports = filterButton + +function filterButton (name, filter, currentFilter, emit) { + var filterClass = filter === currentFilter + ? 'selected' + : '' + + var uri = '#' + name.toLowerCase() + if (uri === '#all') uri = '/' + + return html`
  • + + ${name} + +
  • ` +} diff --git a/example/components/footer/index.js b/example/components/footer/index.js new file mode 100644 index 00000000..a627720f --- /dev/null +++ b/example/components/footer/index.js @@ -0,0 +1,50 @@ +var Component = require('../../../component') +var html = require('nanohtml') + +var clearButton = require('./clear-button') +var filterButton = require('./filter-button') + +module.exports = class Footer extends Component { + constructor (name, state, emit) { + super(name) + this.state = state + this.emit = emit + + this.local = this.state.components.footer = {} + this.setState() + } + + setState () { + this.local.rawTodos = this.state.todos.clock + this.local.rawHref = this.state.href + + this.local.filter = this.state.href.replace(/^\//, '') || '' + this.local.activeCount = this.state.todos.active.length + this.local.hasDone = this.state.todos.done.length || null + } + + update () { + if (this.local.rawTodos !== this.state.todos.clock || + this.local.rawHref !== this.state.href) { + this.setState() + return true + } else { + return false + } + } + + createElement () { + return html`` + } +} diff --git a/example/components/header.js b/example/components/header.js new file mode 100644 index 00000000..e3783d7c --- /dev/null +++ b/example/components/header.js @@ -0,0 +1,32 @@ +var Component = require('../../component') +var html = require('nanohtml') + +module.exports = class Header extends Component { + constructor (name, state, emit) { + super(name) + this.state = state + this.emit = emit + } + + update () { + return false + } + + createElement () { + return html`
    +

    todos

    + +
    ` + } + + createTodo (e) { + var value = e.target.value + if (e.keyCode === 13) { + e.target.value = '' + this.emit('todos:create', value) + } + } +} diff --git a/example/components/info.js b/example/components/info.js new file mode 100644 index 00000000..bd4f2324 --- /dev/null +++ b/example/components/info.js @@ -0,0 +1,16 @@ +var Component = require('../../component') +var html = require('nanohtml') + +module.exports = class Info extends Component { + update () { + return false + } + + createElement () { + return html`` + } +} diff --git a/example/components/todos/index.js b/example/components/todos/index.js new file mode 100644 index 00000000..7fc0c1a8 --- /dev/null +++ b/example/components/todos/index.js @@ -0,0 +1,65 @@ +var Component = require('../../../component') +var html = require('nanohtml') + +var Todo = require('./todo') + +module.exports = class Header extends Component { + constructor (name, state, emit) { + super(name) + this.state = state + this.emit = emit + this.local = this.state.components[name] = {} + this.setState() + } + + setState () { + this.local.rawTodos = this.state.todos.clock + this.local.rawHref = this.state.href + + this.local.allDone = this.state.todos.done.length === this.state.todos.all.length + this.local.filter = this.state.href.replace(/^\//, '') || '' + this.local.todos = this.local.filter === 'completed' + ? this.state.todos.done + : this.local.filter === 'active' + ? this.state.todos.active + : this.state.todos.all + } + + update () { + if (this.local.rawTodos !== this.state.todos.clock || + this.local.rawHref !== this.state.href) { + this.setState() + return true + } else { + return false + } + } + + createElement () { + return html`
    + this.toggleAll()}/> + + +
    ` + } + + createTodo (e) { + var value = e.target.value + if (e.keyCode === 13) { + e.target.value = '' + this.emit('todos:create', value) + } + } + + toggleAll () { + this.emit('todos:toggleAll') + } +} diff --git a/example/components/todos/todo.js b/example/components/todos/todo.js new file mode 100644 index 00000000..69f25dfd --- /dev/null +++ b/example/components/todos/todo.js @@ -0,0 +1,64 @@ +var html = require('nanohtml') + +module.exports = Todo + +function Todo (todo, emit) { + var clx = classList({ completed: todo.done, editing: todo.editing }) + return html` +
  • +
    + + + +
    + +
  • + ` + + function toggle (e) { + emit('todos:toggle', todo.id) + } + + function edit (e) { + emit('todos:edit', todo.id) + } + + function destroy (e) { + emit('todos:delete', todo.id) + } + + function update (e) { + emit('todos:update', { + id: todo.id, + editing: false, + name: e.target.value + }) + } + + function handleEditKeydown (e) { + if (e.keyCode === 13) update(e) // Enter + else if (e.code === 27) emit('todos:unedit') // Escape + } + + function classList (classes) { + var str = '' + var keys = Object.keys(classes) + for (var i = 0, len = keys.length; i < len; i++) { + var key = keys[i] + var val = classes[key] + if (val) str += (key + ' ') + } + return str + } +} diff --git a/example/index.js b/example/index.js index 08fdba89..117d05e3 100644 --- a/example/index.js +++ b/example/index.js @@ -1,385 +1,18 @@ -var Microbounce = require('microbounce') -var Microframe = require('microframe') -var mutate = require('xtend/mutable') -var expose = require('choo-expose') var css = require('sheetify') -var html = require('bel') var choo = require('../') css('todomvc-common/base.css') css('todomvc-app-css/index.css') -// we export this so tests can run -if (module.parent) { - exports.todoStore = todoStore -} else { - var app = choo() - - if (process.env.NODE_ENV !== 'production') { - app.use(require('choo-persist')()) - app.use(require('choo-log')()) - } - app.use(expose()) - app.use(todoStore) - - app.route('/', mainView) - app.route('#active', mainView) - app.route('#completed', mainView) - app.route('*', mainView) - app.mount('body') -} - -function mainView (state, emit) { - emit('log:debug', 'Rendering main view') - return html` - -
    - ${Header(state, emit)} - ${TodoList(state, emit)} - ${Footer(state, emit)} -
    - - - ` -} - -function todoStore (state, emitter) { - // Default values - if (!state.todos) { - state.todos = {} - - state.todos.active = [] - state.todos.done = [] - state.todos.all = [] - - state.todos.idCounter = 0 - } - - // Always reset when application boots - state.todos.input = '' - - // Register emitters after DOM is loaded to speed up DOM loading - emitter.on('DOMContentLoaded', function () { - // CRUD - emitter.on('todos:create', create) - emitter.on('todos:update', update) - emitter.on('todos:delete', del) - - // Special - emitter.on('todos:input', oninput) - - // Shorthand - emitter.on('todos:edit', edit) - emitter.on('todos:unedit', unedit) - emitter.on('todos:toggle', toggle) - emitter.on('todos:toggleAll', toggleAll) - emitter.on('todos:deleteCompleted', deleteCompleted) - }) - - function oninput (text) { - state.todos.input = text - } - - function create (name) { - var item = { - id: state.todos.idCounter, - editing: false, - done: false, - name: name - } - - state.todos.idCounter += 1 - state.todos.active.push(item) - state.todos.all.push(item) - emitter.emit('render') - } - - function edit (id) { - state.todos.all.forEach(function (todo) { - if (todo.id === id) todo.editing = true - }) - emitter.emit('render') - } - - function unedit (id) { - state.todos.all.forEach(function (todo) { - if (todo.id === id) todo.editing = false - }) - emitter.emit('render') - } - - function update (newTodo) { - var todo = state.todos.all.filter(function (todo) { - return todo.id === newTodo.id - })[0] - - if (newTodo.done && todo.done === false) { - state.todos.active.splice(state.todos.active.indexOf(todo), 1) - state.todos.done.push(todo) - } else if (newTodo.done === false && todo.done) { - state.todos.done.splice(state.todos.done.indexOf(todo), 1) - state.todos.active.push(todo) - } - - mutate(todo, newTodo) - emitter.emit('render') - } - - function del (id) { - var i = null - var todo = null - state.todos.all.forEach(function (_todo, j) { - if (_todo.id === id) { - i = j - todo = _todo - } - }) - state.todos.all.splice(i, 1) - - if (todo.done) { - var done = state.todos.done - var doneIndex - done.forEach(function (_todo, j) { - if (_todo.id === id) { - doneIndex = j - } - }) - done.splice(doneIndex, 1) - } else { - var active = state.todos.active - var activeIndex - active.forEach(function (_todo, j) { - if (_todo.id === id) { - activeIndex = j - } - }) - active.splice(activeIndex, 1) - } - emitter.emit('render') - } - - function deleteCompleted (data) { - var done = state.todos.done - done.forEach(function (todo) { - var index = state.todos.all.indexOf(todo) - state.todos.all.splice(index, 1) - }) - state.todos.done = [] - emitter.emit('render') - } - - function toggle (id) { - var todo = state.todos.all.filter(function (todo) { - return todo.id === id - })[0] - var done = todo.done - todo.done = !done - var arr = done ? state.todos.done : state.todos.active - var target = done ? state.todos.active : state.todos.done - var index = arr.indexOf(todo) - arr.splice(index, 1) - target.push(todo) - emitter.emit('render') - } - - function toggleAll (data) { - var todos = state.todos.all - var allDone = state.todos.all.length && - state.todos.done.length === state.todos.all.length - - todos.forEach(function (todo) { - todo.done = !allDone - }) - - if (allDone) { - state.todos.done = [] - state.todos.active = state.todos.all - } else { - state.todos.done = state.todos.all - state.todos.active = [] - } - - emitter.emit('render') - } -} - -function Footer (state, emit) { - var filter = window.location.hash.replace(/^#/, '') - var activeCount = state.todos.active.length - var hasDone = state.todos.done.length - - return html` - - ` - - function filterButton (name, filter, currentFilter, emit) { - var filterClass = filter === currentFilter - ? 'selected' - : '' - - var uri = '#' + name.toLowerCase() - if (uri === '#all') uri = '/' - return html` -
  • - - ${name} - -
  • - ` - } - - function deleteCompleted (emit) { - return html` - - ` - - function deleteAllCompleted () { - emit('todos:deleteCompleted') - } - } -} - -var debounce = Microbounce(256) -var nextFrame = Microframe() -function Header (state, emit) { - return html` -
    -

    todos

    - -
    - ` - - function createTodo (e) { - var value = e.target.value - if (!value) return - if (e.keyCode === 13) { - nextFrame(function () { - emit('todos:input', '') - emit('todos:create', value) - }) - } else { - debounce(function () { - nextFrame(function () { - emit('todos:input', value) - }) - }) - } - } +var app = choo() +if (process.env.NODE_ENV !== 'production') { + app.use(require('choo-devtools')()) } +app.use(require('./stores/todos')) -function TodoItem (todo, emit) { - var clx = classList({ completed: todo.done, editing: todo.editing }) - return html` -
  • -
    - - - -
    - -
  • - ` +app.route('/', require('./views/main')) +app.route('#active', require('./views/main')) +app.route('#completed', require('./views/main')) +app.route('*', require('./views/main')) - function toggle (e) { - emit('todos:toggle', todo.id) - } - - function edit (e) { - emit('todos:edit', todo.id) - } - - function destroy (e) { - emit('todos:delete', todo.id) - } - - function update (e) { - emit('todos:update', { - id: todo.id, - editing: false, - name: e.target.value - }) - } - - function handleEditKeydown (e) { - if (e.keyCode === 13) update(e) // Enter - else if (e.code === 27) emit('todos:unedit') // Escape - } - - function classList (classes) { - var str = '' - var keys = Object.keys(classes) - for (var i = 0, len = keys.length; i < len; i++) { - var key = keys[i] - var val = classes[key] - if (val) str += (key + ' ') - } - return str - } -} - -function TodoList (state, emit) { - var filter = window.location.hash.replace(/^#/, '') - var items = filter === 'completed' - ? state.todos.done - : filter === 'active' - ? state.todos.active - : state.todos.all - - var allDone = state.todos.done.length === state.todos.all.length - - var nodes = items.map(function (todo) { - return TodoItem(todo, emit) - }) - - return html` -
    - - - -
    - ` - - function toggleAll () { - emit('todos:toggleAll') - } -} +module.exports = app.mount('body') diff --git a/example/package.json b/example/package.json index 111ca0bc..fdd34598 100644 --- a/example/package.json +++ b/example/package.json @@ -3,26 +3,19 @@ "version": "1.0.0", "private": true, "scripts": { - "deps": "dependency-check . && dependency-check . --extra --no-dev", - "start": "bankai start --open", - "build": "bankai build --optimize", - "test": "standard && npm run deps && node test.js" + "start": "bankai start", + "build": "bankai build", + "inspect": "bankai inspect", + "test": "standard && node test.js" }, "dependencies": { - "bel": "^4.5.1", - "choo-expose": "^1.0.0", - "choo-log": "^7.0.0-0", - "choo-persist": "^3.0.0", - "microbounce": "^1.0.0", - "microframe": "^1.0.0", + "choo-devtools": "^2.3.3", "sheetify": "^6.0.1", "todomvc-app-css": "^2.0.6", - "todomvc-common": "^1.0.3", - "xtend": "^4.0.1" + "todomvc-common": "^1.0.3" }, "devDependencies": { - "bankai": "^5.4.0", - "dependency-check": "^2.8.0", + "bankai": "^9.0.0-rc6", "standard": "^9.0.1" } } diff --git a/example/stores/todos.js b/example/stores/todos.js new file mode 100644 index 00000000..d6e2efa3 --- /dev/null +++ b/example/stores/todos.js @@ -0,0 +1,149 @@ +module.exports = todoStore + +function todoStore (state, emitter) { + state.todos = { + clock: 0, + idCounter: 0, + active: [], + done: [], + all: [] + } + + emitter.on('DOMContentLoaded', function () { + emitter.on('todos:create', create) + emitter.on('todos:update', update) + emitter.on('todos:delete', del) + emitter.on('todos:edit', edit) + emitter.on('todos:unedit', unedit) + emitter.on('todos:toggle', toggle) + emitter.on('todos:toggleAll', toggleAll) + emitter.on('todos:deleteCompleted', deleteCompleted) + }) + + function create (name) { + var item = { + id: state.todos.idCounter, + editing: false, + done: false, + name: name + } + + state.todos.idCounter += 1 + state.todos.active.push(item) + state.todos.all.push(item) + render() + } + + function edit (id) { + state.todos.all.forEach(function (todo) { + if (todo.id === id) todo.editing = true + }) + render() + } + + function unedit (id) { + state.todos.all.forEach(function (todo) { + if (todo.id === id) todo.editing = false + }) + render() + } + + function update (newTodo) { + var todo = state.todos.all.filter(function (todo) { + return todo.id === newTodo.id + })[0] + + if (newTodo.done && todo.done === false) { + state.todos.active.splice(state.todos.active.indexOf(todo), 1) + state.todos.done.push(todo) + } else if (newTodo.done === false && todo.done) { + state.todos.done.splice(state.todos.done.indexOf(todo), 1) + state.todos.active.push(todo) + } + + Object.assign(todo, newTodo) + render() + } + + function del (id) { + var i = null + var todo = null + state.todos.all.forEach(function (_todo, j) { + if (_todo.id === id) { + i = j + todo = _todo + } + }) + state.todos.all.splice(i, 1) + + if (todo.done) { + var done = state.todos.done + var doneIndex + done.forEach(function (_todo, j) { + if (_todo.id === id) { + doneIndex = j + } + }) + done.splice(doneIndex, 1) + } else { + var active = state.todos.active + var activeIndex + active.forEach(function (_todo, j) { + if (_todo.id === id) { + activeIndex = j + } + }) + active.splice(activeIndex, 1) + } + render() + } + + function deleteCompleted (data) { + var done = state.todos.done + done.forEach(function (todo) { + var index = state.todos.all.indexOf(todo) + state.todos.all.splice(index, 1) + }) + state.todos.done = [] + render() + } + + function toggle (id) { + var todo = state.todos.all.filter(function (todo) { + return todo.id === id + })[0] + var done = todo.done + todo.done = !done + var arr = done ? state.todos.done : state.todos.active + var target = done ? state.todos.active : state.todos.done + var index = arr.indexOf(todo) + arr.splice(index, 1) + target.push(todo) + render() + } + + function toggleAll (data) { + var todos = state.todos.all + var allDone = state.todos.all.length && + state.todos.done.length === state.todos.all.length + + todos.forEach(function (todo) { + todo.done = !allDone + }) + + if (allDone) { + state.todos.done = [] + state.todos.active = state.todos.all + } else { + state.todos.done = state.todos.all + state.todos.active = [] + } + + render() + } + + function render () { + state.todos.clock += 1 + emitter.emit('render') + } +} diff --git a/example/test.js b/example/test.js index dd2049a8..e7cef737 100644 --- a/example/test.js +++ b/example/test.js @@ -2,8 +2,7 @@ var EventEmitter = require('events').EventEmitter var spok = require('spok') var tape = require('tape') -var example = require('./') -var todoStore = example.todoStore +var todoStore = require('./stores/todos') tape('should initialize empty state', function (t) { var emitter = new EventEmitter() @@ -20,25 +19,6 @@ tape('should initialize empty state', function (t) { t.end() }) -tape('restore previous state', function (t) { - var emitter = new EventEmitter() - var state = { - todos: { - idCounter: 100, - active: [], - done: [], - all: [] - } - } - todoStore(state, emitter) - spok(t, state, { - todos: { - idCounter: 100 - } - }) - t.end() -}) - tape('todos:create', function (t) { var emitter = new EventEmitter() var state = {} diff --git a/example/views/main.js b/example/views/main.js new file mode 100644 index 00000000..51135c4d --- /dev/null +++ b/example/views/main.js @@ -0,0 +1,21 @@ +var html = require('nanohtml') // cannot require choo/html because it's a nested repo + +var Header = require('../components/header') +var Footer = require('../components/footer') +var Todos = require('../components/todos') +var Info = require('../components/info') + +module.exports = mainView + +function mainView (state, emit) { + return html` + +
    + ${state.cache(Header, 'header').render()} + ${state.cache(Todos, 'todos').render()} + ${state.cache(Footer, 'footer').render()} +
    + ${state.cache(Info, 'info').render()} + + ` +} diff --git a/html/index.d.ts b/html/index.d.ts new file mode 100644 index 00000000..03f50424 --- /dev/null +++ b/html/index.d.ts @@ -0,0 +1,2 @@ +import * as nanohtml from "nanohtml" +export = nanohtml diff --git a/html/index.js b/html/index.js index e25543b4..95dfe601 100644 --- a/html/index.js +++ b/html/index.js @@ -1 +1 @@ -module.exports = require('bel') +module.exports = require('nanohtml') diff --git a/html/raw.js b/html/raw.js index 975455ce..5de5b80c 100644 --- a/html/raw.js +++ b/html/raw.js @@ -1 +1 @@ -module.exports = require('bel/raw') +module.exports = require('nanohtml/raw') diff --git a/index.d.ts b/index.d.ts index 422d3ea3..27280d32 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,8 +5,8 @@ import * as EventEmitter from 'events' export = Choo declare class Choo { - constructor (opts: Choo.IChoo) - use (callback: (state: Choo.IState, emitter: EventEmitter) => void): void + constructor (opts?: Choo.IChoo) + use (callback: (state: Choo.IState, emitter: EventEmitter, app: this) => void): void route (routeName: string, handler: (state: Choo.IState, emit: (name: string, ...args: any[]) => void) => void): void mount (selector: string): void start (): HTMLElement diff --git a/index.js b/index.js index 914988fe..dc5f658f 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,5 @@ var scrollToAnchor = require('scroll-to-anchor') var documentReady = require('document-ready') -var nanolocation = require('nanolocation') var nanotiming = require('nanotiming') var nanorouter = require('nanorouter') var nanomorph = require('nanomorph') @@ -11,6 +10,8 @@ var nanobus = require('nanobus') var assert = require('assert') var xtend = require('xtend') +var Cache = require('./component/cache') + module.exports = Choo var HISTORY_OBJECT = {} @@ -38,15 +39,31 @@ function Choo (opts) { // properties for internal use only this._historyEnabled = opts.history === undefined ? true : opts.history this._hrefEnabled = opts.href === undefined ? true : opts.href + this._hashEnabled = opts.hash === undefined ? true : opts.hash this._hasWindow = typeof window !== 'undefined' - this._createLocation = nanolocation + this._cache = opts.cache this._loaded = false + this._stores = [] this._tree = null + // state + var _state = { + events: this._events, + components: {} + } + if (this._hasWindow) { + this.state = window.initialState + ? xtend(window.initialState, _state) + : _state + delete window.initialState + } else { + this.state = _state + } + // properties that are part of the API this.router = nanorouter({ curry: true }) this.emitter = nanobus('choo.emit') - this.state = { events: this._events } + this.emit = this.emitter.emit.bind(this.emitter) // listen for title changes; available even when calling .toString() if (this._hasWindow) this.state.title = document.title @@ -59,33 +76,28 @@ function Choo (opts) { } Choo.prototype.route = function (route, handler) { - var timing = nanotiming("choo.route('" + route + "')") + var routeTiming = nanotiming("choo.route('" + route + "')") assert.equal(typeof route, 'string', 'choo.route: route should be type string') assert.equal(typeof handler, 'function', 'choo.handler: route should be type function') - - var self = this - this.router.on(route, function (params) { - return function () { - self.state.params = params - self.state.route = route - var routeTiming = nanotiming("choo.router('" + route + "')") - var res = handler(self.state, function (eventName, data) { - self.emitter.emit(eventName, data) - }) - routeTiming() - return res - } + this.router.on(route, function (state, emit) { + var routerTiming = nanotiming("choo.router('" + route + "')") + var res = handler(state, emit) + routerTiming() + return res }) - timing() + routeTiming() } Choo.prototype.use = function (cb) { assert.equal(typeof cb, 'function', 'choo.use: cb should be type function') - var msg = 'choo.use' - msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg - var timing = nanotiming(msg) - cb(this.state, this.emitter, this) - timing() + var self = this + this._stores.push(function (state) { + var msg = 'choo.use' + msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg + var endTiming = nanotiming(msg) + cb(state, self.emitter, self) + endTiming() + }) } Choo.prototype.start = function () { @@ -95,7 +107,7 @@ Choo.prototype.start = function () { var self = this if (this._historyEnabled) { this.emitter.prependListener(this._events.NAVIGATE, function () { - self.state.query = nanoquery(window.location.search) + self._matchRoute() if (self._loaded) { self.emitter.emit(self._events.RENDER) setTimeout(scrollToAnchor.bind(null, window.location.hash), 0) @@ -125,23 +137,28 @@ Choo.prototype.start = function () { if (self._hrefEnabled) { nanohref(function (location) { var href = location.href - var currHref = window.location.href - if (href === currHref) return + var hash = location.hash + if (href === window.location.href) { + if (!self._hashEnabled && hash) scrollToAnchor(hash) + return + } self.emitter.emit(self._events.PUSHSTATE, href) }) } } - this.state.href = this._createLocation() - this._tree = this.router(this.state.href) - this.state.query = nanoquery(window.location.search) + this._setCache(this.state) + this._stores.forEach(function (initStore) { + initStore(self.state) + }) + + this._matchRoute() + this._tree = this._prerender(this.state) assert.ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href) this.emitter.prependListener(self._events.RENDER, nanoraf(function () { var renderTiming = nanotiming('choo.render') - - self.state.href = self._createLocation() - var newTree = self.router(self.state.href) + var newTree = self._prerender(self.state) assert.ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href) assert.equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' + @@ -165,17 +182,27 @@ Choo.prototype.start = function () { } Choo.prototype.mount = function mount (selector) { - var timing = nanotiming("choo.mount('" + selector + "')") - assert.equal(typeof window, 'object', 'choo.mount: window was not found. .mount() must be called in a browser, use .toString() if running in Node') - assert.equal(typeof selector, 'string', 'choo.mount: selector should be type string') + var mountTiming = nanotiming("choo.mount('" + selector + "')") + if (typeof window !== 'object') { + assert.ok(typeof selector === 'string', 'choo.mount: selector should be type String') + this.selector = selector + mountTiming() + return this + } + + assert.ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement') var self = this documentReady(function () { var renderTiming = nanotiming('choo.render') var newTree = self.start() + if (typeof selector === 'string') { + self._tree = document.querySelector(selector) + } else { + self._tree = selector + } - self._tree = document.querySelector(selector) assert.ok(self._tree, 'choo.mount: could not query selector: ' + selector) assert.equal(self._tree.nodeName, newTree.nodeName, 'choo.mount: The target node <' + self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' + @@ -187,7 +214,7 @@ Choo.prototype.mount = function mount (selector) { renderTiming() }) - timing() + mountTiming() } Choo.prototype.toString = function (location, state) { @@ -197,9 +224,62 @@ Choo.prototype.toString = function (location, state) { assert.equal(typeof location, 'string', 'choo.toString: location should be type string') assert.equal(typeof this.state, 'object', 'choo.toString: state should be type object') - this.state.href = location.replace(/\?.+$/, '') - this.state.query = nanoquery(location) - var html = this.router(location) + var self = this + this._setCache(this.state) + this._stores.forEach(function (initStore) { + initStore(self.state) + }) + + this._matchRoute(location) + var html = this._prerender(this.state) assert.ok(html, 'choo.toString: no valid value returned for the route ' + location) - return html.toString() + assert(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location) + return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString() +} + +Choo.prototype._matchRoute = function (locationOverride) { + var location, queryString + if (locationOverride) { + location = locationOverride.replace(/\?.+$/, '').replace(/\/$/, '') + if (!this._hashEnabled) location = location.replace(/#.+$/, '') + queryString = locationOverride + } else { + location = window.location.pathname.replace(/\/$/, '') + if (this._hashEnabled) location += window.location.hash.replace(/^#/, '/') + queryString = window.location.search + } + var matched = this.router.match(location) + this._handler = matched.cb + this.state.href = location + this.state.query = nanoquery(queryString) + this.state.route = matched.route + this.state.params = matched.params + return this.state +} + +Choo.prototype._prerender = function (state) { + var routeTiming = nanotiming("choo.prerender('" + state.route + "')") + var res = this._handler(state, this.emit) + routeTiming() + return res +} + +Choo.prototype._setCache = function (state) { + var cache = new Cache(state, this.emitter.emit.bind(this.emitter), this._cache) + state.cache = renderComponent + + function renderComponent (Component, id) { + assert.equal(typeof Component, 'function', 'choo.state.cache: Component should be type function') + var args = [] + for (var i = 0, len = arguments.length; i < len; i++) { + args.push(arguments[i]) + } + return cache.render.apply(cache, args) + } + + // When the state gets stringified, make sure `state.cache` isn't + // stringified too. + renderComponent.toJSON = function () { + return null + } } diff --git a/package.json b/package.json index ddc5fe96..76a9b746 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,33 @@ { "name": "choo", - "version": "6.4.0", + "version": "6.13.3", "description": "A 4kb framework for creating sturdy frontend applications", "main": "index.js", "files": [ "index.js", + "index.d.ts", "html/index.js", "html/raw.js", + "html/index.d.ts", + "component/cache.js", + "component/index.js", "dist", "example" ], + "browser": { + "assert": "nanoassert" + }, "scripts": { - "build": "mkdir -p dist/ && browserify index -p bundle-collapser/plugin > dist/bundle.js && browserify index -g unassertify -g uglifyify -p bundle-collapser/plugin | uglifyjs --mangle-props -c unsafe,properties,dead_code,comparisons,evaluate,hoist_funs,if_return,join_vars,pure_getters,reduce_vars,collapse_vars --toplevel > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes", - "deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js", - "inspect": "browserify --full-paths index -g unassertify -g uglifyify | discify --open", + "build": "mkdir -p dist/ && browserify index -s Choo -p bundle-collapser/plugin > dist/bundle.js && browserify index -s Choo -p tinyify > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes", + "deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js --entry ./component/index.js -i nanoassert", + "inspect": "browserify --full-paths index -p tinyify | discify --open", "prepublishOnly": "npm run build", "start": "bankai start example", - "test": "standard && npm run deps && node test.js" + "test": "standard && npm run deps && npm run test:node && npm run test:browser", + "test:node": "node test/node.js | tap-format-spec", + "test:browser": "browserify test/browser.js | tape-run | tap-format-spec" }, - "repository": "yoshuawuyts/choo", + "repository": "choojs/choo", "keywords": [ "client", "frontend", @@ -29,31 +38,34 @@ ], "license": "MIT", "dependencies": { - "bel": "^5.1.3", "document-ready": "^2.0.1", + "nanoassert": "^1.1.0", "nanobus": "^4.2.0", + "nanocomponent": "^6.5.0", "nanohref": "^3.0.0", - "nanolocation": "^1.0.0", + "nanohtml": "^1.1.0", + "nanolru": "^1.0.0", "nanomorph": "^5.1.2", "nanoquery": "^1.1.0", "nanoraf": "^3.0.0", - "nanorouter": "^2.0.0", - "nanotiming": "^6.0.0", + "nanorouter": "^3.0.1", + "nanotiming": "^7.0.0", "scroll-to-anchor": "^1.0.0", "xtend": "^4.0.1" }, "devDependencies": { - "@types/node": "^8.0.20", - "browserify": "^14.3.0", + "@tap-format/spec": "^0.2.0", + "@types/node": "^10.3.1", + "browserify": "^16.2.2", "bundle-collapser": "^1.2.1", - "dependency-check": "^2.8.0", - "discify": "^1.6.0", + "dependency-check": "^3.1.0", + "disc": "^1.3.3", + "hyperscript": "^2.0.2", "pretty-bytes-cli": "^2.0.0", - "spok": "^0.8.1", - "standard": "^10.0.0", + "spok": "^0.9.1", + "standard": "^11.0.1", "tape": "^4.6.3", - "uglify-es": "^3.0.17", - "uglifyify": "^4.0.1", - "unassertify": "^2.0.4" + "tape-run": "^5.0.0", + "tinyify": "^2.2.0" } } diff --git a/test.js b/test.js deleted file mode 100644 index e3b85f33..00000000 --- a/test.js +++ /dev/null @@ -1,19 +0,0 @@ -var tape = require('tape') - -var html = require('./html') -var raw = require('./html/raw') -var choo = require('./') - -tape('should render on the server', function (t) { - var app = choo() - app.route('/', function (state, emit) { - var strong = 'Hello filthy planet' - return html` -

    ${raw(strong)}

    - ` - }) - var res = app.toString('/') - var exp = '

    Hello filthy planet

    ' - t.equal(res.toString().trim(), exp, 'result was OK') - t.end() -}) diff --git a/test/browser.js b/test/browser.js new file mode 100644 index 00000000..18728ed9 --- /dev/null +++ b/test/browser.js @@ -0,0 +1,242 @@ +var tape = require('tape') +var h = require('hyperscript') + +var html = require('../html') +var raw = require('../html/raw') +var choo = require('..') + +tape('should mount in the DOM', function (t) { + t.plan(1) + var app = choo() + var container = init('/', 'p') + app.route('/', function (state, emit) { + var strong = 'Hello filthy planet' + window.requestAnimationFrame(function () { + var exp = '

    Hello filthy planet

    ' + t.equal(container.outerHTML, exp, 'result was OK') + }) + return html` +

    ${raw(strong)}

    + ` + }) + app.mount(container) +}) + +tape('should render with hyperscript', function (t) { + t.plan(1) + var app = choo() + var container = init('/', 'p') + app.route('/', function (state, emit) { + window.requestAnimationFrame(function () { + var exp = '

    Hello filthy planet

    ' + t.equal(container.outerHTML, exp, 'result was OK') + }) + return h('p', h('strong', 'Hello filthy planet')) + }) + app.mount(container) +}) + +tape('should expose a public API', function (t) { + var app = choo() + + t.equal(typeof app.route, 'function', 'app.route prototype method exists') + t.equal(typeof app.toString, 'function', 'app.toString prototype method exists') + t.equal(typeof app.start, 'function', 'app.start prototype method exists') + t.equal(typeof app.mount, 'function', 'app.mount prototype method exists') + t.equal(typeof app.emitter, 'object', 'app.emitter prototype method exists') + + t.equal(typeof app.emit, 'function', 'app.emit instance method exists') + t.equal(typeof app.router, 'object', 'app.router instance object exists') + t.equal(typeof app.state, 'object', 'app.state instance object exists') + + t.end() +}) + +tape('should enable history and hash by defaut', function (t) { + var app = choo() + t.true(app._historyEnabled, 'history enabled') + t.true(app._hrefEnabled, 'href enabled') + t.end() +}) + +tape('router should pass state and emit to view', function (t) { + t.plan(2) + var app = choo() + var container = init() + app.route('/', function (state, emit) { + t.equal(typeof state, 'object', 'state is an object') + t.equal(typeof emit, 'function', 'emit is a function') + return html`
    ` + }) + app.mount(container) +}) + +tape('router should support a default route', function (t) { + t.plan(1) + var app = choo() + var container = init('/random') + app.route('*', function (state, emit) { + t.pass() + return html`
    ` + }) + app.mount(container) +}) + +tape('router should treat hashes as slashes by default', function (t) { + t.plan(1) + var app = choo() + var container = init('/account#security') + app.route('/account/security', function (state, emit) { + t.pass() + return html`
    ` + }) + app.mount(container) +}) + +tape('router should ignore hashes if hash is disabled', function (t) { + t.plan(1) + var app = choo({ hash: false }) + var container = init('/account#security') + app.route('/account', function (state, emit) { + t.pass() + return html`
    ` + }) + app.mount(container) +}) + +tape('cache should default to 100 instances', function (t) { + t.plan(1) + var app = choo() + var container = init() + app.route('/', function (state, emit) { + for (var i = 0; i <= 100; i++) state.cache(Component, i) + state.cache(Component, 0) + return html`
    ` + + function Component (id) { + if (id < i) t.pass('oldest instance was pruned when exceeding 100') + } + }) + app.mount(container) +}) + +tape('cache option should override number of max instances', function (t) { + t.plan(1) + var app = choo({ cache: 1 }) + var container = init() + app.route('/', function (state, emit) { + var instances = 0 + state.cache(Component, instances) + state.cache(Component, instances) + state.cache(Component, 0) + return html`
    ` + + function Component (id) { + if (id < instances) t.pass('oldest instance was pruned when exceeding 1') + instances++ + } + }) + app.mount(container) +}) + +tape('cache option should override default LRU cache', function (t) { + t.plan(2) + var cache = { + get (Component, id) { + t.pass('called get') + }, + set (Component, id) { + t.pass('called set') + } + } + var app = choo({ cache: cache }) + var container = init() + app.route('/', function (state, emit) { + state.cache(Component, 'foo') + return html`
    ` + }) + app.mount(container) + + function Component () {} +}) + +// built-in state + +tape('state should include events', function (t) { + t.plan(2) + var app = choo() + var container = init() + app.route('/', function (state, emit) { + t.ok(state.hasOwnProperty('events'), 'state has event property') + t.ok(Object.keys(state.events).length > 0, 'events object has keys') + return html`
    ` + }) + app.mount(container) +}) + +tape('state should include location on render', function (t) { + t.plan(6) + var app = choo() + var container = init('/foo/bar/file.txt?bin=baz') + app.route('/:first/:second/*', function (state, emit) { + var params = { first: 'foo', second: 'bar', wildcard: 'file.txt' } + t.equal(state.href, '/foo/bar/file.txt', 'state has href') + t.equal(state.route, ':first/:second/*', 'state has route') + t.ok(state.hasOwnProperty('params'), 'state has params') + t.deepEqual(state.params, params, 'params match') + t.ok(state.hasOwnProperty('query'), 'state has query') + t.deepEqual(state.query, { bin: 'baz' }, 'query match') + return html`
    ` + }) + app.mount(container) +}) + +tape('state should include title', function (t) { + t.plan(3) + document.title = 'foo' + var app = choo() + var container = init() + t.equal(app.state.title, 'foo', 'title is match') + app.use(function (state, emitter) { + emitter.on(state.events.DOMTITLECHANGE, function (title) { + t.equal(state.title, 'bar', 'title is changed in state') + t.equal(document.title, 'bar', 'title is changed in document') + }) + }) + app.route('/', function (state, emit) { + emit(state.events.DOMTITLECHANGE, 'bar') + return html`
    ` + }) + app.mount(container) +}) + +tape('state should include cache', function (t) { + t.plan(6) + var app = choo() + var container = init() + app.route('/', function (state, emit) { + t.equal(typeof state.cache, 'function', 'state has cache method') + var cached = state.cache(Component, 'foo', 'arg') + t.equal(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance') + return html`
    ` + }) + app.mount(container) + + function Component (id, state, emit, arg) { + t.equal(id, 'foo', 'id was prefixed to constructor args') + t.equal(typeof state, 'object', 'state was prefixed to constructor args') + t.equal(typeof emit, 'function', 'emit was prefixed to constructor args') + t.equal(arg, 'arg', 'constructor args were forwarded') + } +}) + +// create application container and set location +// (str?, str?) -> Element +function init (location, type) { + location = location ? location.split('#') : ['/', ''] + window.history.replaceState({}, document.title, location[0]) + window.location.hash = location[1] || '' + var container = document.createElement(type || 'div') + document.body.appendChild(container) + return container +} diff --git a/test/node.js b/test/node.js new file mode 100644 index 00000000..eefe4b58 --- /dev/null +++ b/test/node.js @@ -0,0 +1,206 @@ +var tape = require('tape') +var h = require('hyperscript') + +var html = require('../html') +var raw = require('../html/raw') +var choo = require('..') + +tape('should render on the server with nanohtml', function (t) { + var app = choo() + app.route('/', function (state, emit) { + var strong = 'Hello filthy planet' + return html` +

    ${raw(strong)}

    + ` + }) + var res = app.toString('/') + var exp = '

    Hello filthy planet

    ' + t.equal(res.toString().trim(), exp, 'result was OK') + t.end() +}) + +tape('should render on the server with hyperscript', function (t) { + var app = choo() + app.route('/', function (state, emit) { + return h('p', h('strong', 'Hello filthy planet')) + }) + var res = app.toString('/') + var exp = '

    Hello filthy planet

    ' + t.equal(res.toString().trim(), exp, 'result was OK') + t.end() +}) + +tape('should expose a public API', function (t) { + var app = choo() + + t.equal(typeof app.route, 'function', 'app.route prototype method exists') + t.equal(typeof app.toString, 'function', 'app.toString prototype method exists') + t.equal(typeof app.start, 'function', 'app.start prototype method exists') + t.equal(typeof app.mount, 'function', 'app.mount prototype method exists') + t.equal(typeof app.emitter, 'object', 'app.emitter prototype method exists') + + t.equal(typeof app.emit, 'function', 'app.emit instance method exists') + t.equal(typeof app.router, 'object', 'app.router instance object exists') + t.equal(typeof app.state, 'object', 'app.state instance object exists') + + t.end() +}) + +tape('should enable history and hash by defaut', function (t) { + var app = choo() + t.true(app._historyEnabled, 'history enabled') + t.true(app._hrefEnabled, 'href enabled') + t.end() +}) + +tape('router should pass state and emit to view', function (t) { + t.plan(2) + var app = choo() + app.route('/', function (state, emit) { + t.equal(typeof state, 'object', 'state is an object') + t.equal(typeof emit, 'function', 'emit is a function') + return html`
    ` + }) + app.toString('/') + t.end() +}) + +tape('router should support a default route', function (t) { + t.plan(1) + var app = choo() + app.route('*', function (state, emit) { + t.pass() + return html`
    ` + }) + app.toString('/random') + t.end() +}) + +tape('router should treat hashes as slashes by default', function (t) { + t.plan(1) + var app = choo() + app.route('/account/security', function (state, emit) { + t.pass() + return html`
    ` + }) + app.toString('/account#security') + t.end() +}) + +tape('router should ignore hashes if hash is disabled', function (t) { + t.plan(1) + var app = choo({ hash: false }) + app.route('/account', function (state, emit) { + t.pass() + return html`
    ` + }) + app.toString('/account#security') + t.end() +}) + +tape('cache should default to 100 instances', function (t) { + t.plan(1) + var app = choo() + app.route('/', function (state, emit) { + for (var i = 0; i <= 100; i++) state.cache(Component, i) + state.cache(Component, 0) + return html`
    ` + + function Component (id) { + if (id < i) t.pass('oldest instance was pruned when exceeding 100') + } + }) + app.toString('/') + t.end() +}) + +tape('cache option should override number of max instances', function (t) { + t.plan(1) + var app = choo({ cache: 1 }) + app.route('/', function (state, emit) { + var instances = 0 + state.cache(Component, instances) + state.cache(Component, instances) + state.cache(Component, 0) + return html`
    ` + + function Component (id) { + if (id < instances) t.pass('oldest instance was pruned when exceeding 1') + instances++ + } + }) + app.toString('/') + t.end() +}) + +tape('cache option should override default LRU cache', function (t) { + t.plan(2) + var cache = { + get (Component, id) { + t.pass('called get') + }, + set (Component, id) { + t.pass('called set') + } + } + var app = choo({ cache: cache }) + app.route('/', function (state, emit) { + state.cache(Component, 'foo') + return html`
    ` + }) + app.toString('/') + t.end() + + function Component () {} +}) + +// built-in state + +tape('state should include events', function (t) { + t.plan(2) + var app = choo() + app.route('/', function (state, emit) { + t.ok(state.hasOwnProperty('events'), 'state has event property') + t.ok(Object.keys(state.events).length > 0, 'events object has keys') + return html`
    ` + }) + app.toString('/') + t.end() +}) + +tape('state should include location on render', function (t) { + t.plan(6) + var app = choo() + app.route('/:first/:second/*', function (state, emit) { + var params = { first: 'foo', second: 'bar', wildcard: 'file.txt' } + t.equal(state.href, '/foo/bar/file.txt', 'state has href') + t.equal(state.route, ':first/:second/*', 'state has route') + t.ok(state.hasOwnProperty('params'), 'state has params') + t.deepEqual(state.params, params, 'params match') + t.ok(state.hasOwnProperty('query'), 'state has query') + t.deepEqual(state.query, { bin: 'baz' }, 'query match') + return html`
    ` + }) + app.toString('/foo/bar/file.txt?bin=baz') + t.end() +}) + +tape('state should include cache', function (t) { + t.plan(6) + var app = choo() + app.route('/', function (state, emit) { + t.equal(typeof state.cache, 'function', 'state has cache method') + var cached = state.cache(Component, 'foo', 'arg') + t.equal(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance') + return html`
    ` + }) + app.toString('/') + t.end() + + function Component (id, state, emit, arg) { + t.equal(id, 'foo', 'id was prefixed to constructor args') + t.equal(typeof state, 'object', 'state was prefixed to constructor args') + t.equal(typeof emit, 'function', 'emit was prefixed to constructor args') + t.equal(arg, 'arg', 'constructor args were forwarded') + } +})