├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── complex │ ├── index.js │ └── mixed-list.js └── simple │ ├── index.js │ └── tweet-list.js ├── index.js ├── package.json ├── screenshot.png └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: node_js 3 | node_js: 4 | - 'node' 5 | sudo: false 6 | addons: 7 | apt: 8 | packages: 9 | - xvfb 10 | cache: 11 | directories: 12 | - ~/.npm 13 | install: 14 | - export DISPLAY=':99.0' 15 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 16 | - npm i 17 | script: 18 | - npm test 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nanomap Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 1.1.1 - 2017-09-11 6 | * Update dep floor 7 | 8 | ## 1.1.0 - 2017-08-24 9 | * **Added**: Use [`nanoassert`](https://github.com/emilbayes/nanoassert) in the browser 10 | 11 | ## 1.0.0 - 2017-08-20 12 | * Initial release. Beta API basically. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Bret Comnes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nanomap [![stability][0]][1] 2 | [![npm version][2]][3] [![build status][4]][5] 3 | [![downloads][8]][9] [![js-standard-style][10]][11] 4 | 5 | Functionally map data into stateful [nanocomponents][nc]. 6 | 7 | ![](screenshot.png) 8 | 9 | ## Usage 10 | 11 | ```js 12 | var Nanomap = require('nanocomponent/map') 13 | var YoutubeComponent = require('youtube-component') 14 | var TwitterComponent = require('twitter-component') 15 | var OEmbedComponent = require('oembed-component') 16 | var simpleMapper = new Nanomap(opts, TwitterComponent) 17 | // OR 18 | var complexMapper = new Nanomap(opts, { 19 | 'video': YoutubeComponent, 20 | 'tweet': TwitterComponent, 21 | ..., 22 | default: OEmbedComponent 23 | }) 24 | 25 | [{ 26 | id: 'foo123', 27 | opts: { color: 'blue' }, 28 | arguments: {an: 'arg'} // Non-array types passed in as the first argument 29 | }].map(simpleMapper) // Array of rendered DOM nodes from homogeneous components 30 | 31 | [{ 32 | id: 'foo123', 33 | type: 'tweet', 34 | arguments: ['tweet-url'] // component.render.apply(component, arguments) 35 | }].map(complexMapper) // Array of rendered DOM nodes from a heterogeneous set of components 36 | ``` 37 | 38 | ## Installation 39 | ```sh 40 | $ npm install nanomap 41 | ``` 42 | ## API 43 | ### `Nanomap = require('nanomap`) 44 | Import `Nanomap` component class. 45 | 46 | ### `mapper = new Nanomap([opts], Component)` 47 | ### `mapper = Nanomap([opts], { type: Component, [default: Component]})` 48 | Create a new mapper instance that will render data into component instances. 49 | 50 | `opts` include: 51 | 52 | ```js 53 | { 54 | gc: true // clean up unused instances when mapped over 55 | } 56 | ``` 57 | 58 | See examples for more details. 59 | 60 | ## License 61 | [MIT](https://tldrlegal.com/license/mit-license) 62 | 63 | [0]: https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square 64 | [1]: https://nodejs.org/api/documentation.html#documentation_stability_index 65 | [2]: https://img.shields.io/npm/v/nanomap.svg?style=flat-square 66 | [3]: https://npmjs.org/package/nanomap 67 | [4]: https://img.shields.io/travis/bcomnes/nanomap/master.svg?style=flat-square 68 | [5]: https://travis-ci.org/bcomnes/nanomap 69 | [8]: http://img.shields.io/npm/dm/nanomap.svg?style=flat-square 70 | [9]: https://npmjs.org/package/nanomap 71 | [10]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 72 | [11]: https://github.com/feross/standard 73 | [bel]: https://github.com/shama/bel 74 | [yoyoify]: https://github.com/shama/yo-yoify 75 | [md]: https://github.com/patrick-steele-idem/morphdom 76 | [210]: https://github.com/patrick-steele-idem/morphdom/pull/81 77 | [nm]: https://github.com/yoshuawuyts/nanomorph 78 | [ce]: https://github.com/yoshuawuyts/cache-element 79 | [class]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes 80 | [isSameNode]: https://github.com/choojs/nanomorph#caching-dom-elements 81 | [onload]: https://github.com/shama/on-load 82 | [choo]: https://github.com/choojs/choo 83 | [nca]: https://github.com/choojs/nanocomponent-adapters 84 | [nc]: https://github.com/choojs/nanocomponent 85 | -------------------------------------------------------------------------------- /example/complex/index.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | var choo = require('choo') 3 | var MixedList = require('./mixed-list') 4 | var shuffleArray = require('fy-shuffle') 5 | 6 | var app = choo() 7 | app.use(tweetStore) 8 | app.route('/', mainView) 9 | if (typeof window !== 'undefined') { 10 | var container = document.createElement('div') 11 | container.id = 'container' 12 | document.body.appendChild(container) 13 | app.mount('#container') 14 | } 15 | 16 | var list = new MixedList() 17 | 18 | function mainView (state, emit) { 19 | return html` 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |

Embed some stuff with Choo

32 | ${list.render(state.tweets)} 33 |
34 |
` 35 | } 36 | 37 | var moreTweets = [ 38 | 'https://twitter.com/uhhyeahbret/status/898315707254841344', 39 | 'https://www.youtube.com/watch?v=b8HO6hba9ZE', 40 | 'https://twitter.com/uhhyeahbret/status/898214560267608064', 41 | 'https://vimeo.com/229754542', 42 | 'https://twitter.com/uhhyeahbret/status/898196092189253632', 43 | 'https://www.youtube.com/watch?v=bYpKrA233vY' 44 | ] 45 | 46 | function tweetStore (state, emitter) { 47 | state.tweets = [ 48 | 'https://www.youtube.com/watch?v=wGCoAFZiYMw', 49 | 'https://twitter.com/uhhyeahbret/status/897603426518876161', 50 | 'https://twitter.com/yoshuawuyts/status/895338700531535878' 51 | ] 52 | 53 | emitter.on('DOMContentLoaded', function () { 54 | emitter.on('shuffle-urls', function () { 55 | state.tweets = shuffleArray(state.tweets) 56 | emitter.emit('render') 57 | }) 58 | emitter.on('reverse-urls', function () { 59 | state.tweets = state.tweets.reverse() 60 | emitter.emit('render') 61 | }) 62 | emitter.on('add-url', function () { 63 | var a = moreTweets.pop() 64 | if (a) { 65 | state.tweets.unshift(a) 66 | emitter.emit('render') 67 | } 68 | }) 69 | emitter.on('append-url', function () { 70 | var a = moreTweets.pop() 71 | if (a) { 72 | state.tweets.push(a) 73 | emitter.emit('render') 74 | } 75 | }) 76 | emitter.on('pop-url', function () { 77 | var a = state.tweets.pop() 78 | if (a) { 79 | moreTweets.push(a) 80 | emitter.emit('render') 81 | } 82 | }) 83 | emitter.on('shift-url', function () { 84 | var a = state.tweets.shift() 85 | console.log(a) 86 | if (a) { 87 | moreTweets.push(a) 88 | emitter.emit('render') 89 | } 90 | }) 91 | emitter.on('re-render', function () { 92 | emitter.emit('render') 93 | }) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /example/complex/mixed-list.js: -------------------------------------------------------------------------------- 1 | var Tweet = require('twitter-component') 2 | var YoutubeComponent = require('youtube-component') 3 | var Nanocomponent = require('nanocomponent') 4 | var assert = require('assert') 5 | var html = require('bel') 6 | var compare = require('nanocomponent/compare') 7 | var Nanomap = require('../../') 8 | var isArray = Array.isArray 9 | 10 | function shapeData (url, i, list) { 11 | switch (true) { 12 | case /^https?:\/\/(www\.)?youtu\.be/i.test(url): 13 | case /^https?:\/\/(www\.)?youtube\.com/i.test(url): 14 | case /^https?:\/\/(www\.)?vimeo\.com/i.test(url): 15 | case /^https?:\/\/(www\.)?dailymotion\.com/i.test(url): { 16 | return { 17 | id: url, 18 | arguments: url, 19 | type: 'youtube-component', 20 | opts: { 21 | attr: { 22 | width: 480, 23 | height: 270 24 | } 25 | } 26 | } 27 | } 28 | case /^https?:\/\/(www\.)?twitter.com\/.*\/status\/\d*$/i.test(url): { 29 | return { 30 | id: url, 31 | arguments: url, 32 | type: 'twitter-component' 33 | } 34 | } 35 | } 36 | } 37 | 38 | class MixedList extends Nanocomponent { 39 | constructor () { 40 | super() 41 | 42 | this.urls = null 43 | this.mapper = new Nanomap({ 44 | 'youtube-component': YoutubeComponent, 45 | 'twitter-component': Tweet 46 | }) 47 | } 48 | 49 | createElement (urls) { 50 | assert(isArray(urls), 'MixedList: urls must be an array of tweet URLs') 51 | this.urls = urls.slice() // Have to slice since urls is an array 52 | return html` 53 |
54 | ${urls.map(shapeData).map(this.mapper)} 55 |
56 | ` 57 | } 58 | update (urls) { 59 | assert(isArray(urls), 'tweetList must be an array of tweet URLs') 60 | return compare(this.urls, urls) 61 | } 62 | } 63 | 64 | module.exports = MixedList 65 | -------------------------------------------------------------------------------- /example/simple/index.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | var choo = require('choo') 3 | var TweetList = require('./tweet-list') 4 | var shuffleArray = require('fy-shuffle') 5 | 6 | var app = choo() 7 | app.use(tweetStore) 8 | app.route('/', mainView) 9 | if (typeof window !== 'undefined') { 10 | var container = document.createElement('div') 11 | container.id = 'container' 12 | document.body.appendChild(container) 13 | app.mount('#container') 14 | } 15 | 16 | var list = new TweetList() 17 | 18 | function mainView (state, emit) { 19 | return html` 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |

Embed some tweets in Choo

32 | ${list.render(state.tweets)} 33 |
34 |
` 35 | } 36 | 37 | var moreTweets = [ 38 | 'https://twitter.com/uhhyeahbret/status/898315707254841344', 39 | 'https://twitter.com/uhhyeahbret/status/898214560267608064', 40 | 'https://twitter.com/uhhyeahbret/status/898196092189253632' 41 | ] 42 | 43 | function tweetStore (state, emitter) { 44 | state.tweets = [ 45 | 'https://twitter.com/uhhyeahbret/status/897603426518876161', 46 | 'https://twitter.com/yoshuawuyts/status/895338700531535878' 47 | ] 48 | 49 | emitter.on('DOMContentLoaded', function () { 50 | emitter.on('shuffle-tweets', function () { 51 | state.tweets = shuffleArray(state.tweets) 52 | emitter.emit('render') 53 | }) 54 | emitter.on('reverse-tweets', function () { 55 | state.tweets = state.tweets.reverse() 56 | emitter.emit('render') 57 | }) 58 | emitter.on('add-tweet', function () { 59 | var a = moreTweets.pop() 60 | if (a) { 61 | state.tweets.unshift(a) 62 | emitter.emit('render') 63 | } 64 | }) 65 | emitter.on('append-tweet', function () { 66 | var a = moreTweets.pop() 67 | if (a) { 68 | state.tweets.push(a) 69 | emitter.emit('render') 70 | } 71 | }) 72 | emitter.on('pop-tweet', function () { 73 | var a = state.tweets.pop() 74 | if (a) { 75 | moreTweets.push(a) 76 | emitter.emit('render') 77 | } 78 | }) 79 | emitter.on('shift-tweet', function () { 80 | var a = state.tweets.shift() 81 | console.log(a) 82 | if (a) { 83 | moreTweets.push(a) 84 | emitter.emit('render') 85 | } 86 | }) 87 | emitter.on('re-render', function () { 88 | emitter.emit('render') 89 | }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /example/simple/tweet-list.js: -------------------------------------------------------------------------------- 1 | var Tweet = require('twitter-component') 2 | var Nanocomponent = require('nanocomponent') 3 | var assert = require('assert') 4 | var html = require('bel') 5 | var compare = require('nanocomponent/compare') 6 | var Nanomap = require('../../') 7 | var isArray = Array.isArray 8 | 9 | function shapeTweetList (tweetUrl, i, list) { 10 | return { 11 | id: tweetUrl, 12 | opts: { 13 | placeholder: false 14 | }, 15 | arguments: tweetUrl 16 | } 17 | } 18 | 19 | class TweetList extends Nanocomponent { 20 | constructor () { 21 | super() 22 | 23 | this.tweetList = null 24 | this.mapper = new Nanomap({ gc: false }, Tweet) 25 | } 26 | 27 | createElement (tweetList) { 28 | assert(isArray(tweetList), 'tweetList must be an array of tweet URLs') 29 | this.tweetList = tweetList.slice() // Have to slice since tweetList is an array 30 | return html` 31 |
32 | ${tweetList.map(shapeTweetList).map(this.mapper)} 33 |
34 | ` 35 | } 36 | update (tweetList) { 37 | assert(isArray(tweetList), 'tweetList must be an array of tweet URLs') 38 | return compare(this.tweetList, tweetList) 39 | } 40 | } 41 | 42 | module.exports = TweetList 43 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var isArray = Array.isArray 3 | 4 | function Nanomap (opts, Callback) { 5 | if (!(this instanceof Nanomap)) return new Nanomap(opts, Callback) 6 | assert(arguments.length > 0 && arguments.length <= 2, 'Provide the correct number of arguments') 7 | if (arguments.length < 2) { 8 | Callback = opts 9 | opts = {} 10 | } 11 | 12 | opts = Object.assign({ 13 | gc: true 14 | }, opts) 15 | 16 | this.gc = opts.gc 17 | 18 | if (typeof Callback === 'function') { 19 | Callback = { 20 | default: Callback 21 | } 22 | } 23 | 24 | this.Callback = Callback 25 | 26 | this.mapping = false 27 | 28 | this.lastCache = {} // Instance cache 29 | this.nextCache = {} 30 | this.lastTypeCache = {} // Type instance cache 31 | this.nextTypeCache = {} 32 | 33 | return this.render.bind(this) 34 | } 35 | 36 | Nanomap.prototype.render = function (c, i, array) { 37 | assert(c.hasOwnProperty('id'), 38 | 'Nanomap: all map objects must have an \'id\' property') 39 | if (c.hasOwnProperty('type')) { 40 | assert(this.Callback.hasOwnProperty(c.type), 41 | `Nanomap: type ${c.type} not defined for this mapper`) 42 | } else { 43 | assert(this.Callback.hasOwnProperty('default'), 44 | `Nanomap: mappers processing map objects without a 'type' property must implement a 'default' Component`) 45 | } 46 | if (i === 0) { 47 | // Setup a a fresh cache 48 | this.mapping = true 49 | this.lastCache = this.nextCache 50 | this.lastTypeCache = this.nextTypeCache 51 | if (this.gc) { 52 | this.nextCache = {} 53 | this.nextTypeCache = {} 54 | } 55 | } 56 | var instance 57 | var Callback = this.Callback 58 | var args = isArray(c.arguments) ? c.arguments : [c.arguments] 59 | if (this.lastCache[c.id] && this.lastTypeCache[c.id] === (c.type || 'default')) { 60 | instance = this.nextCache[c.id] = this.lastCache[c.id] 61 | this.nextTypeCache[c.id] = (c.type || 'default') 62 | } else { 63 | instance = this.nextCache[c.id] = new Callback[c.type || 'default'](c.opts) 64 | this.nextTypeCache[c.id] = c.type || 'default' 65 | } 66 | 67 | if (i === (array.length - 1)) { 68 | // Clean up old cache references 69 | if (this.gc) { 70 | this.lastCache = null 71 | this.lastTypeCache = null 72 | } 73 | this.mapping = false 74 | } 75 | 76 | return instance.render.apply(instance, args) 77 | } 78 | 79 | module.exports = Nanomap 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nanomap", 3 | "description": "Functionally map data into DOM component instances", 4 | "version": "1.1.1", 5 | "author": "Bret Comnes", 6 | "bugs": { 7 | "url": "https://github.com/bcomnes/nanomap/issues" 8 | }, 9 | "devDependencies": { 10 | "@tap-format/spec": "^0.2.0", 11 | "bankai": "^9.0.0-1", 12 | "bel": "^5.1.1", 13 | "budo": "^10.0.4", 14 | "choo": "^6.0.1", 15 | "dependency-check": "^2.6.0", 16 | "fy-shuffle": "^1.0.0", 17 | "lodash.isequal": "^4.5.0", 18 | "nanocomponent": "^6.4.1", 19 | "npm-run-all": "^4.0.2", 20 | "standard": "^10.0.0", 21 | "tape": "^4.7.0", 22 | "tape-run": "^3.0.0", 23 | "twitter-component": "^1.0.2", 24 | "youtube-component": "^1.1.1" 25 | }, 26 | "homepage": "https://github.com/bcomnes/nanoap#readme", 27 | "keywords": [ 28 | "bel", 29 | "cache-component", 30 | "choo", 31 | "element", 32 | "embed", 33 | "map", 34 | "nanocomponent", 35 | "nanomorph" 36 | ], 37 | "license": "MIT", 38 | "main": "index.js", 39 | "browser": { 40 | "assert": "nanoassert" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/bcomnes/nanomap.git" 45 | }, 46 | "scripts": { 47 | "start": "budo example/complex/index.js --live --open", 48 | "start:simple": "budo example/simple/index.js --live --open", 49 | "start:test": "budo test.js --live --open", 50 | "test": "run-s test:*", 51 | "test:browser": "browserify test.js | tape-run | tap-format-spec", 52 | "test:deps": "dependency-check .", 53 | "test:lint": "standard", 54 | "build": "bankai build example/complex/index.js dist" 55 | }, 56 | "dependencies": { 57 | "nanoassert": "^1.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcomnes/nanomap/aeee81af63eb566d43f165ba2b2c78fdfd2a5ac8/screenshot.png -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var YoutubeComponent = require('youtube-component') 3 | var TwitterComponent = require('twitter-component') 4 | var Nanomap = require('./') 5 | 6 | function makeID () { 7 | return 'testid-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) 8 | } 9 | 10 | function createTestElement () { 11 | var testRoot = document.createElement('div') 12 | testRoot.id = makeID() 13 | document.body.appendChild(testRoot) 14 | return testRoot 15 | } 16 | 17 | function renderAndMount (testEl, data, mapper) { 18 | var els = data.map(mapper) 19 | els.forEach(function (el) { 20 | testEl.appendChild(el) 21 | }) 22 | } 23 | 24 | test('simple mapper', function (t) { 25 | var testRoot = createTestElement() 26 | var simpleMapper = new Nanomap(YoutubeComponent) 27 | var videos = [ 28 | 'https://www.youtube.com/watch?v=b8HO6hba9ZE', 29 | 'https://vimeo.com/229754542', 30 | 'https://www.youtube.com/watch?v=bYpKrA233vY' 31 | ].map(function (url) { 32 | return { id: url, arguments: url } 33 | }) 34 | 35 | t.doesNotThrow(renderAndMount.bind(null, testRoot, videos, simpleMapper), 'Able to render list of videos') 36 | t.true(testRoot.children[0].children[0].src.includes('youtube.com/embed/b8HO6hba9ZE'), 'videos are in page') 37 | t.end() 38 | }) 39 | 40 | test('mixed mapper', function (t) { 41 | var testRoot = createTestElement() 42 | var mixedMapper = new Nanomap({ 43 | video: YoutubeComponent, 44 | tweet: TwitterComponent, 45 | default: TwitterComponent 46 | }) 47 | var videos = [ 48 | 'https://www.youtube.com/watch?v=b8HO6hba9ZE', 49 | 'https://vimeo.com/229754542', 50 | 'https://www.youtube.com/watch?v=bYpKrA233vY' 51 | ].map(function (url) { 52 | return { id: url, arguments: url, type: 'video' } 53 | }) 54 | 55 | var tweets = [ 56 | 'https://twitter.com/uhhyeahbret/status/897603426518876161', 57 | 'https://twitter.com/yoshuawuyts/status/895338700531535878' 58 | ].map(function (url) { 59 | return { id: url, arguments: url, type: 'tweet' } 60 | }) 61 | 62 | delete tweets[0].type 63 | 64 | var data = videos.concat(tweets) 65 | 66 | t.doesNotThrow(renderAndMount.bind(null, testRoot, data, mixedMapper), 'Able to render list of videos') 67 | t.true(testRoot.children[0].children[0].src.includes('youtube.com/embed/b8HO6hba9ZE'), 'videos are in page') 68 | t.end() 69 | }) 70 | --------------------------------------------------------------------------------