├── examples ├── express │ ├── component.json │ ├── lib │ │ ├── boot │ │ │ ├── main.scss │ │ │ └── index.js │ │ ├── module-banana │ │ │ ├── template.hjs │ │ │ ├── template.js │ │ │ ├── index.js │ │ │ └── style.scss │ │ ├── module-orange │ │ │ ├── template.hjs │ │ │ ├── template.js │ │ │ ├── index.js │ │ │ └── style.scss │ │ ├── module-apple │ │ │ ├── template.hjs │ │ │ ├── index.js │ │ │ ├── template.js │ │ │ └── style.scss │ │ ├── model-section │ │ │ ├── client.js │ │ │ ├── routes.js │ │ │ └── server.js │ │ ├── layout-a │ │ │ ├── template.hjs │ │ │ ├── index.js │ │ │ ├── template.js │ │ │ └── style.scss │ │ ├── routes │ │ │ └── index.js │ │ ├── page-about │ │ │ ├── client.js │ │ │ ├── server.js │ │ │ └── view.js │ │ ├── page-home │ │ │ ├── client.js │ │ │ ├── server.js │ │ │ └── view.js │ │ ├── page-links │ │ │ ├── client.js │ │ │ ├── server.js │ │ │ └── view.js │ │ └── wrapper │ │ │ ├── index.hjs │ │ │ └── template.js │ ├── .sass-cache │ │ ├── 02edf621e302928b0497cc899071d282b21c69ae │ │ │ └── style.scssc │ │ ├── c17ef6a51d16627d3a39ba3670d20574256733ff │ │ │ └── style.scssc │ │ ├── f489b5958955e5b353f8ff421f5e0e803b363f01 │ │ │ └── style.scssc │ │ ├── 34148d82a00c750b7f5bb319d56d454f5d3a0706 │ │ │ └── main.scssc │ │ └── e311ee56d011e27e22137ec9eba8360ee5a6567e │ │ │ └── main.scssc │ ├── Makefile │ ├── build │ │ └── build.css │ ├── package.json │ ├── bin │ │ └── compile-templates │ └── app.js ├── lib │ ├── modules │ │ ├── list │ │ │ ├── style.css │ │ │ ├── template.js │ │ │ └── index.js │ │ ├── masthead │ │ │ ├── template.js │ │ │ ├── index.js │ │ │ └── style.css │ │ ├── orange │ │ │ ├── index.js │ │ │ ├── template.js │ │ │ └── style.css │ │ ├── layout-a │ │ │ ├── index.js │ │ │ ├── template.js │ │ │ └── style.css │ │ ├── layout-b │ │ │ ├── index.js │ │ │ ├── style.css │ │ │ └── template.js │ │ ├── apple │ │ │ ├── template.js │ │ │ ├── style.css │ │ │ └── index.js │ │ ├── list-2 │ │ │ ├── template.js │ │ │ ├── style.css │ │ │ └── index.js │ │ ├── strawberry │ │ │ ├── template.js │ │ │ ├── style.css │ │ │ └── index.js │ │ └── list-item │ │ │ ├── template.js │ │ │ ├── index.js │ │ │ └── style.css │ ├── app.css │ ├── examples.css │ ├── examples.js │ ├── collection.js │ ├── database.js │ ├── delegate.js │ └── hogan.js ├── extending │ ├── index.html │ └── script.js ├── helpers │ ├── index.html │ └── script.js ├── interactions │ ├── index.html │ └── script.js ├── getting-started │ ├── index.html │ └── script.js ├── big-collection │ ├── script.js │ └── index.html ├── article-viewer-alt │ ├── script.js │ └── index.html ├── collection │ ├── script.js │ └── index.html ├── todo │ ├── script.js │ └── index.html └── article-viewer │ ├── script.js │ └── index.html ├── .npmignore ├── .github └── CODEOWNERS ├── .gitignore ├── .travis.yml ├── docs ├── templates │ ├── api.hogan │ └── readme.hogan ├── module-interactions.md ├── queries.md ├── injection.md ├── templates.md ├── rendering.md ├── extending-modules.md ├── removing-and-destroying.md ├── module-instantiation.md ├── server-side-rendering.md ├── layout-assembly.md ├── slots.md ├── module-el.md ├── defining-modules.md ├── module-helpers.md ├── introduction.md ├── getting-started.md ├── template-markup.md ├── events.md └── api.md ├── test ├── tests │ ├── module.extend.js │ ├── module.inject.js │ ├── module.classes.js │ ├── module.id.js │ ├── module.modules.js │ ├── module.empty.js │ ├── module.fireStatic.js │ ├── module.toJSON.js │ ├── module.teardown.js │ ├── module._setEl.js │ ├── module._getEl.js │ ├── module.toHTML.js │ ├── module.appendTo.js │ ├── module.fire.js │ ├── helpers.js │ ├── define.js │ ├── module.module.js │ ├── module.setup.js │ ├── module.add.js │ ├── module.destroy.js │ ├── module.mount.js │ ├── module.on.js │ ├── module.remove.js │ ├── module.js │ └── module.render.js └── helpers.js ├── lib ├── index.js ├── define.js ├── fruitmachine.js └── module │ └── events.js ├── LICENSE ├── History.md ├── GruntFile.js ├── package.json ├── README.md └── jest.config.js /examples/express/component.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/lib/modules/list/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /examples/ 3 | /test/ -------------------------------------------------------------------------------- /examples/express/lib/boot/main.scss: -------------------------------------------------------------------------------- 1 | 2 | @import "module-apple/style"; -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Guessed from naming convention 2 | * @Financial-Times/apps 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | artwork 5 | coverage 6 | -------------------------------------------------------------------------------- /examples/express/lib/module-banana/template.hjs: -------------------------------------------------------------------------------- 1 |
Module Banana
-------------------------------------------------------------------------------- /examples/express/lib/module-orange/template.hjs: -------------------------------------------------------------------------------- 1 |
Module Orange
-------------------------------------------------------------------------------- /examples/lib/modules/masthead/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateMasthead = Hogan.compile("Example: {{title}}"); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: 2 | - "npm test" 3 | 4 | language: node_js 5 | 6 | node_js: 7 | - "8" 8 | 9 | sudo: false 10 | -------------------------------------------------------------------------------- /examples/lib/modules/orange/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Orange = fruitmachine.define({ 3 | name: 'orange', 4 | template: templateOrange 5 | }); -------------------------------------------------------------------------------- /examples/lib/modules/layout-a/index.js: -------------------------------------------------------------------------------- 1 | 2 | var LayoutA = fruitmachine.define({ 3 | name: 'layout-a', 4 | template: templateLayoutA 5 | }); -------------------------------------------------------------------------------- /examples/lib/modules/layout-b/index.js: -------------------------------------------------------------------------------- 1 | 2 | var LayoutB = fruitmachine.define({ 3 | name: 'layout-b', 4 | template: templateLayoutB 5 | }); -------------------------------------------------------------------------------- /examples/express/lib/module-apple/template.hjs: -------------------------------------------------------------------------------- 1 |
2 |
{{title}}
3 |
-------------------------------------------------------------------------------- /examples/lib/modules/masthead/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Masthead = fruitmachine.define({ 3 | name: 'masthead', 4 | template: templateMasthead, 5 | tag: 'h1' 6 | }); -------------------------------------------------------------------------------- /examples/lib/modules/masthead/style.css: -------------------------------------------------------------------------------- 1 | 2 | .masthead { 3 | padding: 18px 20px; 4 | font-size: 44px; 5 | font-weight: bold; 6 | line-height: 1em; 7 | } -------------------------------------------------------------------------------- /examples/lib/modules/apple/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateApple = Hogan.compile( 3 | "{{#items}}" + 4 | "
{{title}}
" + 5 | "{{/items}}" 6 | ); -------------------------------------------------------------------------------- /docs/templates/api.hogan: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | {{#jsdoc}} 4 | ### {{title}} 5 | 6 | {{{description.summary}}} 7 | {{#description.body}} 8 | \n{{{description.body}}} 9 | {{/description.body}} 10 | {{/jsdoc}} -------------------------------------------------------------------------------- /examples/lib/modules/list-2/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateList2 = Hogan.compile( 3 | "{{#collection}}" + 4 | "
  • {{title}}
  • " + 5 | "{{/collection}}" 6 | ); -------------------------------------------------------------------------------- /examples/express/.sass-cache/02edf621e302928b0497cc899071d282b21c69ae/style.scssc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/fruitmachine/HEAD/examples/express/.sass-cache/02edf621e302928b0497cc899071d282b21c69ae/style.scssc -------------------------------------------------------------------------------- /examples/express/.sass-cache/c17ef6a51d16627d3a39ba3670d20574256733ff/style.scssc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/fruitmachine/HEAD/examples/express/.sass-cache/c17ef6a51d16627d3a39ba3670d20574256733ff/style.scssc -------------------------------------------------------------------------------- /examples/express/.sass-cache/f489b5958955e5b353f8ff421f5e0e803b363f01/style.scssc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/fruitmachine/HEAD/examples/express/.sass-cache/f489b5958955e5b353f8ff421f5e0e803b363f01/style.scssc -------------------------------------------------------------------------------- /examples/lib/modules/strawberry/template.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var templateStrawberry = Hogan.compile( 4 | "
    " + 5 | "" + 6 | "
    " 7 | ); -------------------------------------------------------------------------------- /examples/express/lib/module-banana/template.js: -------------------------------------------------------------------------------- 1 | module.exports = new Hogan(function(c,p,i){var _=this;_.b(i=i||"");_.b("
    Module Banana
    ");return _.fl();;}); -------------------------------------------------------------------------------- /examples/express/lib/module-orange/template.js: -------------------------------------------------------------------------------- 1 | module.exports = new Hogan(function(c,p,i){var _=this;_.b(i=i||"");_.b("
    Module Orange
    ");return _.fl();;}); -------------------------------------------------------------------------------- /examples/lib/modules/apple/style.css: -------------------------------------------------------------------------------- 1 | 2 | .apple {} 3 | 4 | .apple-item { 5 | padding: 20px; 6 | border-bottom: solid 1px #DDD; 7 | cursor: pointer; 8 | } 9 | 10 | .apple-item:hover { 11 | background: #EEE; 12 | } -------------------------------------------------------------------------------- /examples/lib/modules/list/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateList = function(data) { 3 | var string = ''; 4 | var l = data.children.length; 5 | for (var i = 0; i < l; i++) string += data.children[i].child; 6 | return string; 7 | }; -------------------------------------------------------------------------------- /examples/lib/modules/list-item/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateListItem = function(data) { 3 | return '' + 4 | data.title + 5 | '
    ×
    '; 6 | }; -------------------------------------------------------------------------------- /examples/express/lib/model-section/client.js: -------------------------------------------------------------------------------- 1 | 2 | exports.get = function(id) { 3 | // 1. Query local database 4 | // 2. If not found make http request 5 | // 3. Data should be returned in exactly 6 | // the same json format. 7 | }; -------------------------------------------------------------------------------- /examples/lib/modules/list-2/style.css: -------------------------------------------------------------------------------- 1 | 2 | .list-2_item { 3 | list-style-type: none; 4 | padding: 14px; 5 | border-bottom: solid 1px #DDD; 6 | cursor: pointer; 7 | } 8 | 9 | .list-2_item:hover { 10 | background: #EEE; 11 | } -------------------------------------------------------------------------------- /examples/lib/modules/strawberry/style.css: -------------------------------------------------------------------------------- 1 | 2 | .strawberry { 3 | padding: 20px; 4 | } 5 | 6 | .strawberry_input { 7 | width: 100%; 8 | padding: 6px; 9 | font-size: 1.4em; 10 | } 11 | 12 | .strawberry_input:focus { 13 | outline: none; 14 | } -------------------------------------------------------------------------------- /examples/express/Makefile: -------------------------------------------------------------------------------- 1 | 2 | build: 3 | mkdir -p build 4 | ./bin/compile-templates 5 | browserify lib/boot/index.js -o build/build.js 6 | sass lib/boot/main.scss --load-path lib/ --compass > build/build.css 7 | 8 | all: build 9 | 10 | .PHONY: build -------------------------------------------------------------------------------- /examples/express/lib/boot/index.js: -------------------------------------------------------------------------------- 1 | global.Hogan = require('hogan.js/lib/template').Template; 2 | global.app = {}; 3 | 4 | var fruitmachine = require('../../../../lib/'); 5 | var routes = require('../routes'); 6 | 7 | app.view = fruitmachine(window.layout).setup(); -------------------------------------------------------------------------------- /examples/express/lib/model-section/routes.js: -------------------------------------------------------------------------------- 1 | var section = require('./server'); 2 | 3 | exports.get = function(req, res, next) { 4 | var id = req.params.id; 5 | section.get(id, function(err, data) { 6 | if (err) return next(err); 7 | res.json(data); 8 | }); 9 | }; -------------------------------------------------------------------------------- /examples/lib/app.css: -------------------------------------------------------------------------------- 1 | 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | -moz-box-sizing: border-box; 6 | box-sizing: border-box; 7 | } 8 | 9 | html, 10 | body, 11 | .app { 12 | height: 100%; 13 | font-family: helvetica, sans-serif; 14 | color: #444; 15 | } -------------------------------------------------------------------------------- /examples/lib/modules/orange/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateOrange = Hogan.compile( 3 | "
    {{date}}
    " + 4 | "
    {{title}}
    " + 5 | "
    {{{body}}}
    " + 6 | "
    by {{author}}
    " 7 | ); -------------------------------------------------------------------------------- /examples/lib/modules/layout-a/template.js: -------------------------------------------------------------------------------- 1 | 2 | var templateLayoutA = Hogan.compile( 3 | "
    {{{1}}}
    " + 4 | "
    " + 5 | "
    {{{2}}}
    " + 6 | "
    {{{3}}}
    " + 7 | "
    " 8 | ); -------------------------------------------------------------------------------- /examples/express/lib/layout-a/template.hjs: -------------------------------------------------------------------------------- 1 |
    2 |
    {{{slot_1}}}
    3 |
    4 |
    {{{slot_2}}}
    5 |
    {{{slot_3}}}
    6 |
    7 |
    -------------------------------------------------------------------------------- /examples/express/lib/layout-a/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var fm = require('../../../../lib/'); 7 | var template = require('./template'); 8 | 9 | /** 10 | * Exports 11 | */ 12 | 13 | module.exports = fm.define({ 14 | module: 'layout-a', 15 | template: template 16 | }); -------------------------------------------------------------------------------- /examples/express/lib/module-apple/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var fm = require('../../../../lib/'); 7 | var template = require('./template'); 8 | 9 | /** 10 | * Exports 11 | */ 12 | 13 | module.exports = fm.define({ 14 | module: 'apple', 15 | template: template 16 | }); -------------------------------------------------------------------------------- /examples/express/build/build.css: -------------------------------------------------------------------------------- 1 | /*========================================================================= 2 | Module Apple 3 | ========================================================================== */ 4 | .module-apple { 5 | padding: 14px; } 6 | 7 | .module-apple_title { 8 | font: 30px 'Helvetica'; } 9 | -------------------------------------------------------------------------------- /examples/express/lib/module-banana/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var fm = require('../../../../lib/'); 7 | var template = require('./template'); 8 | 9 | /** 10 | * Exports 11 | */ 12 | 13 | module.exports = fm.define({ 14 | module: 'banana', 15 | template: template 16 | }); -------------------------------------------------------------------------------- /examples/express/lib/module-orange/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var fm = require('../../../../lib/'); 7 | var template = require('./template'); 8 | 9 | /** 10 | * Exports 11 | */ 12 | 13 | module.exports = fm.define({ 14 | module: 'orange', 15 | template: template 16 | }); -------------------------------------------------------------------------------- /examples/extending/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Extending 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/helpers/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Helpers 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/interactions/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Interactions 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/express/lib/module-apple/template.js: -------------------------------------------------------------------------------- 1 | module.exports = new Hogan(function(c,p,i){var _=this;_.b(i=i||"");_.b("
    ");_.b("\n" + i);_.b("
    ");_.b(_.v(_.f("title",c,p,0)));_.b("
    ");_.b("\n" + i);_.b("
    ");return _.fl();;}); -------------------------------------------------------------------------------- /examples/express/lib/routes/index.js: -------------------------------------------------------------------------------- 1 | var page = require('page'); 2 | var home = require('../page-home/client'); 3 | var about = require('../page-about/client'); 4 | var links = require('../page-links/client'); 5 | 6 | page('/', home); 7 | page('/about', about); 8 | page('/links', links); 9 | 10 | page({ dispatch: false }); -------------------------------------------------------------------------------- /examples/getting-started/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Getting started 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/express/lib/page-about/client.js: -------------------------------------------------------------------------------- 1 | var content = document.querySelector('.js-app_content'); 2 | var View = require('./view'); 3 | 4 | var database = { 5 | title: 'This is the About page' 6 | }; 7 | 8 | module.exports = function() { 9 | app.view = View(database); 10 | 11 | app.view 12 | .render() 13 | .inject(content); 14 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-home/client.js: -------------------------------------------------------------------------------- 1 | var content = document.querySelector('.js-app_content'); 2 | var View = require('./view'); 3 | 4 | var database = { 5 | title: 'This is the Home page' 6 | }; 7 | 8 | module.exports = function() { 9 | app.view = View(database); 10 | 11 | app.view 12 | .render() 13 | .inject(content); 14 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-links/client.js: -------------------------------------------------------------------------------- 1 | var content = document.querySelector('.js-app_content'); 2 | var View = require('./view'); 3 | 4 | var database = { 5 | title: 'This is the Links page' 6 | }; 7 | 8 | module.exports = function() { 9 | app.view = View(database); 10 | 11 | app.view 12 | .render() 13 | .inject(content); 14 | }; -------------------------------------------------------------------------------- /examples/express/lib/module-apple/style.scss: -------------------------------------------------------------------------------- 1 | 2 | /*========================================================================= 3 | Module Apple 4 | ========================================================================== */ 5 | 6 | .module-apple { 7 | padding: 14px; 8 | } 9 | 10 | .module-apple_title { 11 | font: 30px 'Helvetica'; 12 | } -------------------------------------------------------------------------------- /examples/express/lib/module-banana/style.scss: -------------------------------------------------------------------------------- 1 | 2 | /*========================================================================= 3 | Module Banana 4 | ========================================================================== */ 5 | 6 | .module-banana { 7 | padding: 14px; 8 | } 9 | 10 | .module-banana_title { 11 | font: 30px 'Helvetica'; 12 | } -------------------------------------------------------------------------------- /examples/express/lib/module-orange/style.scss: -------------------------------------------------------------------------------- 1 | 2 | /*========================================================================= 3 | Module Orange 4 | ========================================================================== */ 5 | 6 | .module-orange { 7 | padding: 14px; 8 | } 9 | 10 | .module-orange_title { 11 | font: 30px 'Helvetica'; 12 | } -------------------------------------------------------------------------------- /examples/express/.sass-cache/34148d82a00c750b7f5bb319d56d454f5d3a0706/main.scssc: -------------------------------------------------------------------------------- 1 | 3.2.3 (Media Mark) 2 | 6ff5c9da0165256d24783873997ea9acafeff1bf 3 | o:Sass::Tree::RootNode 4 | :@children[o:Sass::Tree::ImportNode :@imported_file0;[: @options{:@imported_filename"module-apple/style: 5 | @linei:@template0; @ :@has_childrenT; i; "# 6 | @import "module-apple/style"; -------------------------------------------------------------------------------- /examples/express/.sass-cache/e311ee56d011e27e22137ec9eba8360ee5a6567e/main.scssc: -------------------------------------------------------------------------------- 1 | 3.2.3 (Media Mark) 2 | 6ff5c9da0165256d24783873997ea9acafeff1bf 3 | o:Sass::Tree::RootNode 4 | :@children[o:Sass::Tree::ImportNode :@imported_file0;[: @options{:@imported_filename"module-apple/style: 5 | @linei:@template0; @ :@has_childrenT; i; "# 6 | @import "module-apple/style"; -------------------------------------------------------------------------------- /examples/lib/modules/layout-b/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .layout-b { 4 | height: 100%; 5 | } 6 | 7 | .layout-b_header { 8 | height: 80px; 9 | margin-bottom: -80px; 10 | border-bottom: solid 1px #DDD; 11 | } 12 | 13 | .layout-b_content { 14 | height: 100%; 15 | padding-top: 80px; 16 | } 17 | 18 | .layout-b_child-2 { 19 | border-bottom: solid 1px #DDD; 20 | } 21 | -------------------------------------------------------------------------------- /examples/lib/modules/list-item/index.js: -------------------------------------------------------------------------------- 1 | 2 | var ListItem = fruitmachine.define({ 3 | name: 'list-item', 4 | template: templateListItem, 5 | 6 | setup: function() { 7 | var self = this; 8 | this.button = this.el.querySelector('.list-item_close-button'); 9 | this.button.addEventListener('click', function() { 10 | self.fire('closebuttonclick', self); 11 | }); 12 | } 13 | }); -------------------------------------------------------------------------------- /examples/lib/modules/orange/style.css: -------------------------------------------------------------------------------- 1 | 2 | .orange { 3 | padding: 20px; 4 | } 5 | 6 | .orange_date, 7 | .orange_byline { 8 | margin-bottom: 10px; 9 | font-style: italic; 10 | color: #AAA; 11 | } 12 | 13 | .orange_title { 14 | margin-bottom: 10px; 15 | font-size: 2em; 16 | font-weight: bold; 17 | } 18 | 19 | .orange_body { 20 | margin-bottom: 10px; 21 | line-height: 1.4em 22 | } -------------------------------------------------------------------------------- /examples/lib/modules/apple/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Apple = fruitmachine.define({ 3 | name: 'apple', 4 | template: templateApple, 5 | 6 | setup: function() { 7 | var self = this; 8 | this.delegate = new Delegate(this.el); 9 | this.delegate.on('click', '.apple-item', function(event, el) { 10 | var id = el.getAttribute('data-id'); 11 | self.fire('itemclick', id); 12 | }); 13 | } 14 | }); -------------------------------------------------------------------------------- /examples/express/lib/page-about/server.js: -------------------------------------------------------------------------------- 1 | var View = require('./view'); 2 | 3 | var database = { 4 | title: 'This is the About page' 5 | }; 6 | 7 | /* 8 | * GET home page. 9 | */ 10 | 11 | module.exports = function(req, res){ 12 | var view = View(database); 13 | res.expose(view.toJSON(), 'layout'); 14 | res.render('wrapper', { 15 | title: 'About', 16 | body: view.toHTML() 17 | }); 18 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-links/server.js: -------------------------------------------------------------------------------- 1 | var View = require('./view'); 2 | 3 | var database = { 4 | title: 'This is the Links page' 5 | }; 6 | 7 | /* 8 | * GET home page. 9 | */ 10 | 11 | module.exports = function(req, res){ 12 | var view = View(database); 13 | res.expose(view.toJSON(), 'layout'); 14 | res.render('wrapper', { 15 | title: 'Links', 16 | body: view.toHTML() 17 | }); 18 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-home/server.js: -------------------------------------------------------------------------------- 1 | var View = require('./view'); 2 | 3 | var database = { 4 | title: 'This is the Home page' 5 | }; 6 | 7 | /* 8 | * GET home page. 9 | */ 10 | 11 | module.exports = function(req, res){ 12 | var view = View(database); 13 | res.expose(view.toJSON(), 'layout'); 14 | res.render('wrapper', { 15 | title: 'Home', 16 | body: view.toHTML() 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /examples/lib/modules/layout-b/template.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var templateLayoutB = function(data){ 4 | return "
    " + (data[1]||'') + "
    " + 5 | "
    " + 6 | "
    " + 7 | "
    " + (data[2]||'') + "
    " + 8 | "
    " + (data[3]||'') + "
    " + 9 | "
    " + 10 | "
    "; 11 | }; -------------------------------------------------------------------------------- /test/tests/module.extend.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Extend', function() { 3 | test("Defining reserved methods should rewrite keys with prefixed with '_'", function() { 4 | var setup = jest.fn(); 5 | var Module = fruitmachine.Module.extend({ 6 | module: 'foobar', 7 | setup: setup 8 | }); 9 | 10 | expect(Module.prototype._module).toBe('foobar'); 11 | expect(Module.prototype._setup).toBe(setup); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /examples/getting-started/script.js: -------------------------------------------------------------------------------- 1 | 2 | var Apple = fruitmachine.define({ 3 | module: 'apple', 4 | template: function(){ return 'I am an apple'; } 5 | }); 6 | 7 | var Layout = fruitmachine.define({ 8 | module: 'layout', 9 | template: function(data){ return data.child1; } 10 | }); 11 | 12 | var layout = new Layout(); 13 | var apple = new Apple({ id: 'child1' }); 14 | 15 | layout 16 | .add(apple) 17 | .render() 18 | .inject(document.body); -------------------------------------------------------------------------------- /examples/lib/modules/strawberry/index.js: -------------------------------------------------------------------------------- 1 | 2 | var Strawberry = fruitmachine.define({ 3 | name: 'strawberry', 4 | template: templateStrawberry, 5 | 6 | setup: function() { 7 | var self = this; 8 | this.form = this.el.querySelector('form'); 9 | this.form.addEventListener('submit', function(event) { 10 | event.preventDefault(); 11 | var field = self.form[0]; 12 | self.fire('submit', field.value); 13 | field.value = ''; 14 | }); 15 | } 16 | }); -------------------------------------------------------------------------------- /examples/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app" 7 | }, 8 | "dependencies": { 9 | "body-parser": "^1.18.3", 10 | "errorhandler": "^1.5.0", 11 | "express": "^4.16.4", 12 | "express-expose": "^0.3.4", 13 | "hjs": "*", 14 | "hogan.js": "^3.0.2", 15 | "method-override": "^3.0.0", 16 | "morgan": "^1.9.1", 17 | "page": "^1.11.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/express/lib/model-section/server.js: -------------------------------------------------------------------------------- 1 | 2 | var database = { 3 | home: { 4 | id: home, 5 | views: [ 6 | { 7 | id: 'slot_1', 8 | module: 'apple', 9 | model: { 10 | title: 'Home' 11 | } 12 | } 13 | ] 14 | } 15 | }; 16 | 17 | 18 | exports.get = function(id, callback) { 19 | // 1. Query server database 20 | // 2. Run callback passing data 21 | 22 | var data = database[id]; 23 | if (!data) return callback('Not found'); 24 | callback(null, data); 25 | }; -------------------------------------------------------------------------------- /examples/express/lib/wrapper/index.hjs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 13 |
    {{{body}}}
    14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/lib/modules/list-item/style.css: -------------------------------------------------------------------------------- 1 | 2 | .list-item { 3 | padding: 20px; 4 | border-bottom: solid 1px #DDD; 5 | } 6 | 7 | .list-item_checkbox { 8 | margin-right: 10px; 9 | } 10 | 11 | .list-item_close-button { 12 | float: right; 13 | width: 1.25em; 14 | height: 1.25em; 15 | margin-top: -3px; 16 | padding: 0px; 17 | border-radius: 50%; 18 | background: #DDD; 19 | color: #FFF; 20 | text-align: center; 21 | font-size: 1.3em; 22 | font-weight: bold; 23 | cursor: pointer; 24 | } 25 | 26 | .list-item_close-button:hover { 27 | background: #444; 28 | } -------------------------------------------------------------------------------- /examples/lib/modules/layout-a/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .layout-a { 4 | height: 100%; 5 | } 6 | 7 | .layout-a_header { 8 | height: 80px; 9 | margin-bottom: -80px; 10 | font-size: 44px; 11 | font-weight: bold; 12 | line-height: 1em; 13 | border-bottom: solid 1px #DDD; 14 | } 15 | 16 | .layout-a_content { 17 | height: 100%; 18 | padding-top: 80px; 19 | } 20 | 21 | .layout-a_list { 22 | float: left; 23 | width: 20%; 24 | height: 100%; 25 | border-right: solid 1px #DDD; 26 | } 27 | 28 | .layout-a_body { 29 | float: left; 30 | width: 80%; 31 | height: 100%; 32 | } -------------------------------------------------------------------------------- /examples/express/lib/layout-a/template.js: -------------------------------------------------------------------------------- 1 | module.exports = new Hogan(function(c,p,i){var _=this;_.b(i=i||"");_.b("
    ");_.b("\n" + i);_.b("
    ");_.b(_.t(_.f("slot_1",c,p,0)));_.b("
    ");_.b("\n" + i);_.b("
    ");_.b("\n" + i);_.b("
    ");_.b(_.t(_.f("slot_2",c,p,0)));_.b("
    ");_.b("\n" + i);_.b("
    ");_.b(_.t(_.f("slot_3",c,p,0)));_.b("
    ");_.b("\n" + i);_.b("
    ");_.b("\n" + i);_.b("
    ");return _.fl();;}); -------------------------------------------------------------------------------- /test/tests/module.inject.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#inject()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should inject the view element into the given element.", function() { 10 | var sandbox = document.createElement('div'); 11 | 12 | viewToTest 13 | .render() 14 | .inject(sandbox); 15 | 16 | expect(viewToTest.el).toBe(sandbox.firstElementChild); 17 | }); 18 | 19 | afterEach(function() { 20 | helpers.emptySandbox(); 21 | helpers.destroyView(); 22 | viewToTest = null; 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /docs/module-interactions.md: -------------------------------------------------------------------------------- 1 | ## Interacting with the DOM 2 | 3 | Sometimes, modules need to interact with the DOM, for example to register event handlers or set up a non-fruitmachine component. The `mount` lifecycle method is called whenever a module is associated with a new DOM element, allowing you to perform setup that requires the DOM: 4 | 5 | ```js 6 | var Apple = fruitmachine.define({ 7 | name: 'apple', 8 | template: function() { return '' }, 9 | 10 | mount: function() { 11 | this.el.addEventListener('click', function() { 12 | alert('clicked'); 13 | }); 14 | }, 15 | }); 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/helpers/script.js: -------------------------------------------------------------------------------- 1 | 2 | var myHelper = function(module) { 3 | 4 | // Add functionality 5 | module.on('before setup', function() { /* 1 */ 6 | module.sayName = function() { 7 | alert ('My name is ' + module.name); 8 | }; 9 | }); 10 | 11 | // Tidy up 12 | module.on('teardown', function() { 13 | delete module.sayName; 14 | }); 15 | }; 16 | 17 | var Apple = fruitmachine.define({ 18 | name: 'apple', 19 | template: function(){ return ''; }, 20 | helpers: [ 21 | myHelper 22 | ] 23 | }); 24 | 25 | var apple = new Apple() 26 | .render() 27 | .inject(document.body) 28 | .setup(); 29 | 30 | apple.sayName(); -------------------------------------------------------------------------------- /examples/interactions/script.js: -------------------------------------------------------------------------------- 1 | var Apple = fruitmachine.define({ 2 | module: 'apple', 3 | template: function(){ return ''; }, 4 | setup: function() { 5 | var self = this; 6 | this.button = this.el.querySelector('button'); 7 | this.onButtonClick = function() { 8 | alert('tearing down'); 9 | self.teardown(); 10 | }; 11 | 12 | this.button.addEventListener('click', this.onButtonClick); 13 | }, 14 | teardown: function() { 15 | this.button.removeEventListener('click', this.onButtonClick); 16 | } 17 | }); 18 | 19 | var apple = new Apple() 20 | .render() 21 | .inject(document.body) 22 | .setup(); -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 2 | /*jslint browser:true, node:true*/ 3 | 4 | /** 5 | * FruitMachine Singleton 6 | * 7 | * Renders layouts/modules from a basic layout definition. 8 | * If views require custom interactions devs can extend 9 | * the basic functionality. 10 | * 11 | * @version 0.6.0 12 | * @copyright The Financial Times Limited [All Rights Reserved] 13 | * @author Wilson Page 14 | */ 15 | 16 | 'use strict'; 17 | 18 | /** 19 | * Module Dependencies 20 | */ 21 | 22 | var fruitMachine = require('./fruitmachine'); 23 | var Model = require('model'); 24 | 25 | /** 26 | * Exports 27 | */ 28 | 29 | module.exports = fruitMachine({ Model: Model }); 30 | -------------------------------------------------------------------------------- /examples/express/bin/compile-templates: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var exec = require('child_process').exec; 4 | var hogan = require('hogan.js'); 5 | var fs = require('fs'); 6 | var path = require('path'); 7 | var root = __dirname + '/../'; 8 | var command = 'find lib/**/*.hjs'; 9 | 10 | exec(command, { cwd: root }, function(err, stdout) { 11 | var templates = stdout.trim().split('\n'); 12 | templates.forEach(compile); 13 | }); 14 | 15 | function compile(filepath) { 16 | filepath = root + filepath; 17 | var string = fs.readFileSync(filepath, 'utf8'); 18 | var fn = 'module.exports = new Hogan(' + hogan.compile(string, { asString: true }) + ');'; 19 | var jspath = path.dirname(filepath) + '/template.js'; 20 | fs.writeFileSync(jspath, fn); 21 | } 22 | -------------------------------------------------------------------------------- /examples/lib/modules/list/index.js: -------------------------------------------------------------------------------- 1 | 2 | var List = fruitmachine.define({ 3 | name: 'list', 4 | template: templateList, 5 | 6 | initialize: function(options) { 7 | this.collection = options && options.collection; 8 | this.addItem = this.addItem.bind(this); 9 | this.removeItem = this.removeItem.bind(this); 10 | 11 | if (!this.collection) return; 12 | 13 | this.collection.forEach(this.addItem); 14 | this.collection.on('add', this.addItem); 15 | this.collection.on('remove', this.removeItem); 16 | }, 17 | 18 | addItem: function(model) { 19 | this.add(new ListItem({ 20 | id: model.get('id'), 21 | model: model 22 | })); 23 | }, 24 | 25 | removeItem: function(model) { 26 | var listItem = this.id(model.get('id')); 27 | this.remove(listItem); 28 | } 29 | }); -------------------------------------------------------------------------------- /examples/express/lib/page-about/view.js: -------------------------------------------------------------------------------- 1 | var fruitmachine = require('../../../../lib/'); 2 | 3 | // Require these views so that 4 | // fruitmachine registers them 5 | var LayoutA = require('../layout-a'); 6 | var ModuleApple = require('../module-apple'); 7 | var ModuleOrange = require('../module-orange'); 8 | var ModuleBanana = require('../module-banana'); 9 | 10 | module.exports = function(data) { 11 | var layout = { 12 | module: 'layout-a', 13 | children: [ 14 | { 15 | id: 'slot_1', 16 | module: 'apple', 17 | model: { 18 | title: data.title 19 | } 20 | }, 21 | { 22 | id: 'slot_2', 23 | module: 'banana' 24 | }, 25 | { 26 | id: 'slot_3', 27 | module: 'orange' 28 | } 29 | ] 30 | }; 31 | 32 | return fruitmachine(layout); 33 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-home/view.js: -------------------------------------------------------------------------------- 1 | var fruitmachine = require('../../../../lib/'); 2 | 3 | // Require these views so that 4 | // fruitmachine registers them 5 | var LayoutA = require('../layout-a'); 6 | var ModuleApple = require('../module-apple'); 7 | var ModuleOrange = require('../module-orange'); 8 | var ModuleBanana = require('../module-banana'); 9 | 10 | module.exports = function(data) { 11 | var layout = { 12 | module: 'layout-a', 13 | children: [ 14 | { 15 | id: 'slot_1', 16 | module: 'apple', 17 | model: { 18 | title: data.title 19 | } 20 | }, 21 | { 22 | id: 'slot_2', 23 | module: 'orange' 24 | }, 25 | { 26 | id: 'slot_3', 27 | module: 'banana' 28 | } 29 | ] 30 | }; 31 | 32 | return fruitmachine(layout); 33 | }; -------------------------------------------------------------------------------- /examples/express/lib/page-links/view.js: -------------------------------------------------------------------------------- 1 | var fruitmachine = require('../../../../lib/'); 2 | 3 | // Require these views so that 4 | // fruitmachine registers them 5 | var LayoutA = require('../layout-a'); 6 | var ModuleApple = require('../module-apple'); 7 | var ModuleOrange = require('../module-orange'); 8 | var ModuleBanana = require('../module-banana'); 9 | 10 | module.exports = function(data) { 11 | var layout = { 12 | module: 'layout-a', 13 | children: [ 14 | { 15 | id: 'slot_1', 16 | module: 'orange' 17 | }, 18 | { 19 | id: 'slot_2', 20 | module: 'apple', 21 | model: { 22 | title: data.title 23 | } 24 | }, 25 | { 26 | id: 'slot_3', 27 | module: 'banana' 28 | } 29 | ] 30 | }; 31 | 32 | return fruitmachine(layout); 33 | }; -------------------------------------------------------------------------------- /docs/queries.md: -------------------------------------------------------------------------------- 1 | ## Finding modules 2 | 3 | When working with a nested (DOM like) view structure, you need a way get to child view instances. FruitMachine has three APIs for this: 4 | 5 | #### View#module(); 6 | 7 | Returns a single descendant View instance by module type. Similar to `Element.prototype.querySelector`. 8 | 9 | ```js 10 | apple.module('orange'); 11 | //=> orange 12 | ``` 13 | 14 | #### View#modules(); 15 | 16 | Returns a list of descendant View instance by module type. Similar to `Element.prototype.querySelectorAll`. 17 | 18 | ```js 19 | apple.modules('orange'); 20 | //=> [orange, orange, ...] 21 | ``` 22 | 23 | #### View#id(); 24 | 25 | Returns a single descendant View instance by id. Similar to `document.getElementById`. 26 | 27 | ```js 28 | apple.id('my_orange_1'); 29 | //=> orange 30 | ``` 31 | -------------------------------------------------------------------------------- /examples/extending/script.js: -------------------------------------------------------------------------------- 1 | var Apple = fruitmachine.define({ 2 | module: 'apple', 3 | template: function(){ return ''; }, 4 | initialize: function() { 5 | alert('we are similar'); 6 | }, 7 | setup: function() { 8 | alert('but i am an apple'); 9 | } 10 | }); 11 | 12 | var Pear = fruitmachine.define( 13 | Apple.extend({ 14 | module: 'pear', 15 | template: function(){ return ''; }, 16 | setup: function() { 17 | alert('and i am an pear'); 18 | } 19 | }) 20 | ); 21 | 22 | var apple = new Apple(); 23 | //=> alert - 'we are similar' 24 | 25 | var pear = new Pear(); 26 | //=> alert - 'we are similar' 27 | 28 | apple 29 | .render() 30 | .appendTo(document.body); 31 | 32 | pear 33 | .render() 34 | .appendTo(document.body); 35 | 36 | apple.setup(); 37 | //=> alert - 'but i am an apple' 38 | 39 | pear.setup(); 40 | //=> alert - 'but i am an pear' -------------------------------------------------------------------------------- /examples/big-collection/script.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var Model = fruitmachine.Model; 7 | var len = 1000; 8 | var items = []; 9 | 10 | for (var i = 1; i <= len; i++) { 11 | items.push({ id: i, title: 'Item ' + i }); 12 | } 13 | 14 | var collection = new Collection(items); 15 | 16 | /** 17 | * Locals 18 | */ 19 | 20 | var app = document.getElementById('app'); 21 | var button = document.getElementById('button'); 22 | var layout = new LayoutB({ 23 | children: { 24 | 1: { 25 | module: 'masthead', 26 | model: { 27 | title: 'Big Collection' 28 | } 29 | }, 30 | 2: { 31 | module: 'list-2', 32 | collection: collection 33 | } 34 | } 35 | }); 36 | 37 | console.profile('big-collection'); 38 | 39 | layout 40 | .render() 41 | .inject(app) 42 | .setup(); 43 | 44 | console.profileEnd('big-collection'); 45 | -------------------------------------------------------------------------------- /docs/injection.md: -------------------------------------------------------------------------------- 1 | ## DOM injection 2 | 3 | Once you have [assembled](layout-assembly.md) and [rendered](rendering.md) your View you'll want to inject it into the DOM at some point. *FruitMachine* has multiple ways of doing this: 4 | 5 | - `.inject()` Empties the contents of the given element, then appends `view.el`. 6 | - `.appendTo()` Appends `view.el` to the given Element. 7 | - `.insertBefore(, )` Appends `view.el` to the given Element as a previous sibling of the second Element parameter. 8 | 9 | ### `View#inject()` 10 | 11 | ```js 12 | var container = document.querySelector('.container'); 13 | var apple = new Apple(); 14 | 15 | apple.render(); 16 | apple.inject(container); 17 | ``` 18 | 19 | ### `View#appendTo()` 20 | 21 | ```js 22 | var list = document.querySelector('.list'); 23 | var apple = new Apple(); 24 | 25 | apple.render(); 26 | apple.appendTo(list); 27 | ``` -------------------------------------------------------------------------------- /examples/express/lib/wrapper/template.js: -------------------------------------------------------------------------------- 1 | module.exports = new Hogan(function(c,p,i){var _=this;_.b(i=i||"");_.b("");_.b("\n" + i);_.b("");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b(_.v(_.f("title",c,p,0)));_.b("");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b("
    ");_.b(_.t(_.f("body",c,p,0)));_.b("
    ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b(" ");_.b("\n" + i);_.b("");return _.fl();;}); -------------------------------------------------------------------------------- /examples/express/lib/layout-a/style.scss: -------------------------------------------------------------------------------- 1 | 2 | /*========================================================================= 3 | Layout A 4 | ========================================================================== */ 5 | 6 | .layout-a { 7 | 8 | } 9 | 10 | /* Slot 1 11 | -------------------------------------------------------------------------- */ 12 | 13 | .layout-a_slot-1 { 14 | 15 | } 16 | 17 | 18 | /*========================================================================= 19 | Region 1 20 | ========================================================================== */ 21 | 22 | .layout-a_region-1 { 23 | 24 | } 25 | 26 | /* Slot 2 27 | -------------------------------------------------------------------------- */ 28 | 29 | .layout-a_slot-2 { 30 | float: left; 31 | margin-right: 300px; 32 | } 33 | 34 | /* Slot 3 35 | -------------------------------------------------------------------------- */ 36 | 37 | .layout-a_slot-3 { 38 | float: right; 39 | width: 300px; 40 | } -------------------------------------------------------------------------------- /examples/lib/modules/list-2/index.js: -------------------------------------------------------------------------------- 1 | 2 | var List2 = fruitmachine.define({ 3 | name: 'list-2', 4 | template: templateList2, 5 | 6 | initialize: function(options) { 7 | this.collection = options.collection || []; 8 | this.setCollection = this.setCollection.bind(this); 9 | if (!this.collection) return; 10 | this.setCollection(); 11 | 12 | this.collection.on('add', this.setCollection); 13 | this.collection.on('remove', this.setCollection); 14 | }, 15 | 16 | setup: function() { 17 | var self = this; 18 | this.delegate = new Delegate(this.el); 19 | this.delegate.on('click', '.js-list_item', function(event, el) { 20 | var id = el.getAttribute('data-id'); 21 | var model = self.collection.id(id); 22 | 23 | self.collection.remove(model); 24 | self 25 | .render() 26 | .setup(); 27 | }); 28 | }, 29 | 30 | teardown: function() { 31 | this.delegate.off(); 32 | }, 33 | 34 | setCollection: function() { 35 | this.model.set('collection', this.collection.toJSON()); 36 | } 37 | }); -------------------------------------------------------------------------------- /docs/templates.md: -------------------------------------------------------------------------------- 1 | ## Templates 2 | 3 | Each module (or view) must have a template assigned with it. This is done when the module is defined (via `fruitmachine.define();`). Each module expects to be given a template function, that when called (and optionally passed a data object) will return html. You template function can just be a plain JavaScript function, but we recommend you use a templating library like [Hogan](http://twitter.github.io/hogan.js/). 4 | 5 | The following are equivalent: 6 | 7 | ```js 8 | var template = function(data) { return '
    ' + data.text + '
    '; }; 9 | 10 | var MyModule = fruitmachine.define({ 11 | name: 'myModule', 12 | template: template 13 | }); 14 | ``` 15 | 16 | ```js 17 | var template = Hogan.compile('
    {{text}}
    '); 18 | 19 | var MyModule = fruitmachine.define({ 20 | name: 'myModule', 21 | template: template 22 | }); 23 | ``` 24 | 25 | **Note:** If the object passed has a render function (somewhat templating convention), then that will be used as the module's template function. -------------------------------------------------------------------------------- /docs/rendering.md: -------------------------------------------------------------------------------- 1 | ## Rendering 2 | 3 | When you have [assembled](layout-assembly.md) your modules and populated them with the required [data](module-instantiation.md#options) you can call `.render()`. Render recursively renders the nested module structure by calling each module's render function, from the bottom up, and then turns the output html string into a DOM node (`view.el`). `Module#render()` (like most module methods) returns the module instance (`this`) to allow for chaining. 4 | 5 | #### Re-rendering 6 | 7 | Often data changes and you need to re-render your modules. Render replaces the root element with a new one, which means you can easily keep Views up to date. This will remove any child module elements and immediately re-mount them. 8 | 9 | ```js 10 | var model = new fruitmachine.Model({ name: 'Wilson' }); 11 | var apple = new Apple({ model: model }); 12 | 13 | apple 14 | .render() 15 | .inject(document.body); 16 | 17 | //...some time later 18 | 19 | apple.model.set('name', 'Matt'); 20 | apple.render(); 21 | ``` 22 | -------------------------------------------------------------------------------- /test/tests/module.classes.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#classes()', function() { 3 | test("Should be able to define classes on the base class", function() { 4 | var View = fruitmachine.define({ 5 | classes: ['foo', 'bar'] 6 | }); 7 | 8 | var view = new View() 9 | .render(); 10 | 11 | expect(!!~view.el.className.indexOf('foo')).toBe(true) 12 | expect(!!~view.el.className.indexOf('bar')).toBe(true); 13 | }); 14 | 15 | test("Should be able to manipulate the classes array at any time", function() { 16 | var apple = new helpers.Views.Apple(); 17 | 18 | apple.classes.push('foo'); 19 | apple.render(); 20 | 21 | expect(!!~apple.el.className.indexOf('foo')).toBe(true); 22 | }); 23 | 24 | test("Should be able to define classes at instnatiation", function() { 25 | var apple = new helpers.Views.Apple({ 26 | classes: ['foo', 'bar'] 27 | }); 28 | 29 | apple.render(); 30 | 31 | expect(!!~apple.el.className.indexOf('foo')).toBe(true); 32 | expect(!!~apple.el.className.indexOf('bar')).toBe(true); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/tests/module.id.js: -------------------------------------------------------------------------------- 1 | describe('View#id()', function() { 2 | var viewToTest; 3 | 4 | beforeEach(function() { 5 | viewToTest = helpers.createView(); 6 | }); 7 | 8 | test("Should return a child by id.", function() { 9 | var layout = new Layout({ 10 | children: { 11 | 1: { 12 | module: 'apple', 13 | id: 'some_id' 14 | } 15 | } 16 | }); 17 | 18 | var child = layout.id('some_id'); 19 | expect(child).toBeDefined(); 20 | }); 21 | 22 | test("Should return the view's own id if no arguments given.", function() { 23 | var id = 'a_view_id'; 24 | var view = new Apple({ id: id }); 25 | 26 | expect(view.id()).toBe(id); 27 | }); 28 | 29 | test("Should not return the view's own id the first argument is undefined", function() { 30 | var id = 'a_view_id'; 31 | var view = new Apple({ id: id }); 32 | expect(view.id(undefined)).toBeUndefined(); 33 | }); 34 | 35 | afterEach(function() { 36 | helpers.destroyView(); 37 | viewToTest = null; 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /examples/article-viewer-alt/script.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Usage 4 | */ 5 | 6 | // Create the fruitmachine View 7 | var layout = new LayoutA(); 8 | var masthead = new Masthead({ model: { title: 'Article Viewer Alt' }}); 9 | var apple = new Apple(); 10 | var orange = new Orange(); 11 | var articles = database.getSync(); 12 | 13 | layout 14 | .add(masthead, 1) 15 | .add(apple, 2) 16 | .add(orange, 3); 17 | 18 | // Set some data 19 | // on module apple. 20 | apple.model.set({ items: articles }); 21 | 22 | // Render the view, 23 | // inject it into the 24 | // DOM and call setup. 25 | layout 26 | .render() 27 | .inject(document.getElementById('app')) 28 | .setup(); 29 | 30 | // Make an async call for the first article data 31 | setArticle(articles[0].id); 32 | 33 | // Setup a listener on the 'apple' view. 34 | apple.on('itemclick', setArticle); 35 | 36 | /** 37 | * Methods 38 | */ 39 | 40 | function setArticle(id) { 41 | database.getAsync(id, function(article) { 42 | orange.model.set(article); 43 | orange 44 | .render() 45 | .setup(); 46 | }); 47 | } -------------------------------------------------------------------------------- /examples/collection/script.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var Model = fruitmachine.Model; 7 | var items = database.getSync(); 8 | var collection = new Collection(items); 9 | 10 | /** 11 | * Locals 12 | */ 13 | 14 | var app = document.getElementById('app'); 15 | var button = document.getElementById('button'); 16 | var list1 = new List({ collection: collection }); 17 | var list2 = new List({ collection: collection }); 18 | 19 | list1 20 | .render() 21 | .appendTo(app) 22 | .setup() 23 | .on('closebuttonclick', onCloseButtonClick); 24 | 25 | list2 26 | .render() 27 | .appendTo(app) 28 | .setup() 29 | .on('closebuttonclick', onCloseButtonClick); 30 | 31 | function onCloseButtonClick() { 32 | var view = this.event.target; 33 | collection.remove(view.model); 34 | } 35 | 36 | // Button to add new items 37 | button.addEventListener('click', function() { 38 | var l = collection.length + 1; 39 | collection.add({ id: l, title: "I'm item " + l }); 40 | 41 | list1 42 | .render() 43 | .setup(); 44 | 45 | list2 46 | .render() 47 | .setup(); 48 | }); -------------------------------------------------------------------------------- /test/tests/module.modules.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#modules()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | var layout = new Layout({}); 7 | var apple = new Apple({ id: 'slot_1' }); 8 | var orange = new Orange({ id: 'slot_2' }); 9 | var pear = new Pear({ id: 'slot_3' }); 10 | 11 | layout 12 | .add(apple) 13 | .add(orange) 14 | .add(pear); 15 | 16 | viewToTest = layout; 17 | }); 18 | 19 | test("Should return all descendant views matching the given module type", function() { 20 | var oranges = viewToTest.modules('orange'); 21 | var pears = viewToTest.modules('pear'); 22 | 23 | expect(oranges.length).toBe(1); 24 | expect(pears.length).toBe(1); 25 | }); 26 | 27 | test("Should return multiple views if they exist", function() { 28 | viewToTest 29 | .add({ module: 'pear' }); 30 | 31 | var pears = viewToTest.modules('pear'); 32 | 33 | expect(pears.length).toBe(2); 34 | }); 35 | 36 | afterEach(function() { 37 | helpers.destroyView(viewToTest); 38 | viewToTest = null; 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /examples/todo/script.js: -------------------------------------------------------------------------------- 1 | 2 | var Model = fruitmachine.Model; 3 | 4 | /** 5 | * Usage 6 | */ 7 | 8 | // Create the fruitmachine View 9 | var layout = new LayoutB(); 10 | var masthead = new Masthead({ model: { title: 'Todo' }}); 11 | var strawberry = new Strawberry(); 12 | var list = new List(); 13 | var collection = []; 14 | 15 | layout 16 | .add(masthead, 1) 17 | .add(strawberry, 2) 18 | .add(list, 3) 19 | .on('submit', onSubmit) 20 | .on('closebuttonclick', onCloseButtonClick); 21 | 22 | // Render the view, 23 | // inject it into the 24 | // DOM and call setup. 25 | layout 26 | .render() 27 | .inject(document.getElementById('app')) 28 | .setup(); 29 | 30 | 31 | function onSubmit(value) { 32 | var model = new Model({ title: value }); 33 | var item = new ListItem({ model: model }).render(); 34 | 35 | list.add(item, { at: 0, inject: true }); 36 | item.setup(); 37 | 38 | collection.unshift(model); 39 | } 40 | 41 | function onCloseButtonClick(item) { 42 | var index = collection.indexOf(item.model); 43 | collection.splice(index, 1); 44 | this.event.target.remove(); 45 | } 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /test/tests/module.empty.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#empty()', function() { 3 | test("Should run destroy on each child", function() { 4 | var Apple = helpers.Views.Apple; 5 | var list = new helpers.Views.List(); 6 | var apple1 = new Apple(); 7 | var apple2 = new Apple(); 8 | var destroy1 = jest.spyOn(apple1, 'destroy'); 9 | var destroy2 = jest.spyOn(apple2, 'destroy'); 10 | 11 | list 12 | .add(apple1) 13 | .add(apple2) 14 | .render() 15 | .setup(); 16 | 17 | list.empty(); 18 | 19 | expect(destroy1).toHaveBeenCalledTimes(1); 20 | expect(destroy2).toHaveBeenCalledTimes(1); 21 | }); 22 | 23 | test("Should remove elements from the DOM", function() { 24 | var Apple = helpers.Views.Apple; 25 | var list = new helpers.Views.List(); 26 | var apple1 = new Apple(); 27 | var apple2 = new Apple(); 28 | 29 | list 30 | .add(apple1) 31 | .add(apple2) 32 | .render() 33 | .inject(helpers.sandbox) 34 | .setup(); 35 | 36 | list.empty(); 37 | 38 | expect(list.el.querySelector('apple')).toBeNull(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 The Financial Times Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /docs/extending-modules.md: -------------------------------------------------------------------------------- 1 | ## Extending 2 | 3 | It is common in an application to have module's that share behavior, but are slightly different. In this case you can extend from modules you have already defined ([working example](http://ftlabs.github.io/fruitmachine/examples/extending)). 4 | 5 | ```js 6 | 7 | var Apple = fruitmachine.define({ 8 | name: 'apple', 9 | template: function(){ return ''; }, 10 | initialize: function() { 11 | alert('we are similar'); 12 | }, 13 | setup: function() { 14 | alert('but i am an apple'); 15 | } 16 | }); 17 | 18 | var Pear = fruitmachine.define( 19 | Apple.extend({ 20 | name: 'pear', 21 | template: function(){ return ''; }, 22 | setup: function() { 23 | alert('and i am an pear'); 24 | } 25 | }) 26 | ); 27 | 28 | var apple = new Apple(); 29 | //=> alert - 'we are similar' 30 | 31 | var pear = new Pear(); 32 | //=> alert - 'we are similar' 33 | 34 | apple 35 | .render() 36 | .appendTo(document.body); 37 | 38 | pear 39 | .render() 40 | .appendTo(document.body); 41 | 42 | apple.setup(); 43 | //=> alert - 'but i am an apple' 44 | 45 | pear.setup(); 46 | //=> alert - 'but i am an pear' 47 | ``` -------------------------------------------------------------------------------- /test/tests/module.fireStatic.js: -------------------------------------------------------------------------------- 1 | describe('View#fireStatic()', function() { 2 | var viewToTest; 3 | 4 | beforeEach(function() { 5 | viewToTest = helpers.createView(); 6 | }); 7 | 8 | test("Should run on callbacks registered on the view", function() { 9 | var spy = jest.fn(); 10 | 11 | viewToTest.on('testevent', spy); 12 | viewToTest.fireStatic('testevent'); 13 | expect(spy).toHaveBeenCalledTimes(1); 14 | }); 15 | 16 | test("Events should not bubble up to parent views", function() { 17 | var spy = jest.fn(); 18 | var child = viewToTest.module('orange'); 19 | 20 | viewToTest.on('childtestevent', spy); 21 | child.fireStatic('childtestevent'); 22 | expect(spy).not.toHaveBeenCalled(); 23 | }); 24 | 25 | test("Should pass arguments to the callback", function() { 26 | var spy = jest.fn(); 27 | var arg1 = 'arg1'; 28 | var arg2 = 'arg2'; 29 | var arg3 = 'arg3'; 30 | 31 | viewToTest.on('childtestevent', spy); 32 | viewToTest.fireStatic('childtestevent', arg1, arg2, arg3); 33 | 34 | expect(spy).toHaveBeenCalledWith(arg1, arg2, arg3); 35 | }); 36 | 37 | afterEach(function() { 38 | helpers.destroyView(); 39 | viewToTest = null; 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /examples/lib/examples.css: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Some styles to aid example layut 4 | */ 5 | 6 | body { 7 | font: 16px 'Helvetica'; 8 | color: #333; 9 | background: #EEE; 10 | } 11 | 12 | ul { 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | .js-console { 18 | position: fixed; 19 | left: 0; 20 | bottom: 0; 21 | right: 0; 22 | max-height: 33%; 23 | padding: 1em 1em 0 1em; 24 | font: 1em/1.35em 'courier new'; 25 | color: #FFF; 26 | overflow: auto; 27 | background: #222; 28 | box-shadow: inset 0 1px 4px rgba(0,0,0,0.5); 29 | border-top: solid 6px purple; 30 | } 31 | 32 | .js-console li { 33 | margin-bottom: 0.5em; 34 | list-style-type: none; 35 | } 36 | 37 | .js-console i { 38 | color: rgba(255, 255, 255, 0.5); 39 | } 40 | 41 | .js-console input { 42 | box-sizing: border-box; 43 | width: 100%; 44 | padding: 0.5em 1em; 45 | border: 0; 46 | border-top: dotted 1px #555; 47 | color: rgba(255, 255, 255, 0.01); 48 | font: inherit; 49 | outline: none; 50 | background-color: #222; 51 | } 52 | 53 | .js-console form { 54 | position: relative; 55 | } 56 | 57 | .js-console form:before { 58 | content: '$'; 59 | position: absolute; 60 | left: 0; 61 | top: 0; 62 | margin: 0.75em 0; 63 | } -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 0.5.1 / 2013-05-22 2 | ================== 3 | 4 | * change all descendant `model.el` properties set at `Module#render()` 5 | * change expose `fruitmachine.Events` 6 | * remove `Module#inDOM()` 7 | 8 | 0.5.0 / 2013-05-22 9 | ================== 10 | 11 | * add support for third party models 12 | * change `fruitmachine.View` => `fruitmachine.Module` 13 | * change component.json to bower.json 14 | 15 | 0.4.2 / 2013-05-20 16 | ================== 17 | 18 | * change allow `name` key as an alternative to `module` in module definitions 19 | 20 | 0.4.1 / 2013-05-17 21 | ================== 22 | 23 | * fix bug with delegate event listeners not being passed correct aguments 24 | 25 | 0.4.0 / 2013-05-17 26 | ================== 27 | 28 | * change `slot` now defines placement over `id` 29 | * change `children` can now be an object (keys as `slot`) or an array. 30 | * change `id` is optional, used only for queries. 31 | * change 'LazyViews' can only be instantiated `var view = fruitmachine(layout)` no longer via `fruitmachine.View`. 32 | * add event hooks on `View` for `'tojson'` and `'inflation'` 33 | * add events to `fruitmachine` namespace to allow `fruitmachine.on('inflation')` -------------------------------------------------------------------------------- /examples/express/app.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var express = require('express'); 7 | var expose = require('express-expose'); 8 | var http = require('http'); 9 | var path = require('path'); 10 | var morgan = require('morgan'); 11 | var bodyParser = require('body-parser'); 12 | var methodOverride = require('method-override'); 13 | var errorHandler = require('errorhandler'); 14 | 15 | global.Hogan = require('hogan.js').Template; 16 | 17 | var app = express(); 18 | app = expose(app); 19 | 20 | app.set('port', process.env.PORT || 3000); 21 | app.set('views', __dirname + '/lib'); 22 | app.set('view engine', 'hjs'); 23 | app.use(morgan('dev')); 24 | app.use(bodyParser()); 25 | app.use(methodOverride()); 26 | 27 | if (app.settings.env === 'development') { 28 | app.use(errorHandler()); 29 | } 30 | 31 | app.get('/', require('./lib/page-home/server')); 32 | app.get('/about', require('./lib/page-about/server')); 33 | app.get('/links', require('./lib/page-links/server')); 34 | 35 | app.use(express['static'](path.join(__dirname, 'build'))); 36 | 37 | http.createServer(app).listen(app.get('port'), function(){ 38 | console.log("Express server listening on port " + app.get('port')); 39 | }); 40 | -------------------------------------------------------------------------------- /examples/lib/examples.js: -------------------------------------------------------------------------------- 1 | var jsConsole = document.createElement('div'); 2 | var list = document.createElement('ul'); 3 | 4 | jsConsole.className = 'js-console'; 5 | jsConsole.appendChild(list); 6 | 7 | // Inject the output console html 8 | document.body.appendChild(jsConsole); 9 | 10 | createInput(); 11 | 12 | /** 13 | * Inserts a new output item into 14 | * the output console. 15 | * 16 | * @param {String} string 17 | * @return void 18 | */ 19 | function log(string) { 20 | list.insertAdjacentHTML('beforeend', '
  • ' + string + '
  • '); 21 | } 22 | 23 | function exec(command) { 24 | var output = eval(command.replace('var', '')); 25 | if (console && console.log) console.log(output); 26 | log(command + '
    >> ' + output + ''); 27 | } 28 | 29 | function createInput() { 30 | var form = document.createElement('form'); 31 | var field = document.createElement('input'); 32 | 33 | field.type = 'text'; 34 | field.placeholder = 'playground console...'; 35 | form.appendChild(field); 36 | 37 | field.focus(); 38 | 39 | form.addEventListener('submit', function(event) { 40 | event.preventDefault(); 41 | var val = field.value; 42 | field.value = ''; 43 | exec(val); 44 | }); 45 | 46 | jsConsole.appendChild(form); 47 | field.focus(); 48 | } 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/article-viewer/script.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Layout 4 | */ 5 | 6 | var layout = { 7 | module: 'layout-a', 8 | children: { 9 | 1: { 10 | module: 'masthead', 11 | model: { 12 | title: 'Article viewer' 13 | } 14 | }, 15 | 2: { 16 | module: 'apple' 17 | }, 18 | 3: { 19 | module: 'orange' 20 | } 21 | } 22 | }; 23 | 24 | /** 25 | * Usage 26 | */ 27 | 28 | // Create the fruitmachine View 29 | var view = fruitmachine(layout); 30 | var apple = view.module('apple'); 31 | 32 | // Get some data from our database. 33 | var articles = database.getSync(); 34 | 35 | // Set some data 36 | // on module apple. 37 | apple.model.set({ items: articles }); 38 | 39 | // Render the view, 40 | // inject it into the 41 | // DOM and call setup. 42 | view 43 | .render() 44 | .inject(document.getElementById('app')) 45 | .setup() 46 | .on('itemclick', setArticle); 47 | 48 | // Make an async call for the first article data 49 | setArticle(articles[0].id); 50 | 51 | /** 52 | * Methods 53 | */ 54 | 55 | function setArticle(id) { 56 | database.getAsync(id, function(article) { 57 | var orange = view.module('orange'); 58 | 59 | orange.model.set(article); 60 | 61 | orange 62 | .render() 63 | .setup(); 64 | }); 65 | } -------------------------------------------------------------------------------- /examples/lib/collection.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var Model = fruitmachine.Model; 7 | var events = fruitmachine.Events; 8 | 9 | function Collection(items) { 10 | this.items = []; 11 | this._ids = {}; 12 | items.forEach(this.add, this); 13 | this._updateLength(); 14 | } 15 | 16 | Collection.prototype.add = function(item) { 17 | var id = item.id || item._id; 18 | item = (item instanceof Model) ? item : new Model(item); 19 | this.items.push(item); 20 | this._ids[id] = item; 21 | this._updateLength(); 22 | this.fire('add', item); 23 | }; 24 | 25 | Collection.prototype.remove = function(item) { 26 | var index = this.items.indexOf(item); 27 | if (!~index) return; 28 | this.items.splice(index, 1); 29 | this._updateLength(); 30 | item.fire('remove'); 31 | this.fire('remove', item); 32 | }; 33 | 34 | Collection.prototype.id = function(id) { 35 | return this._ids[id]; 36 | }; 37 | 38 | Collection.prototype.forEach = function() { 39 | [].forEach.apply(this.items, arguments); 40 | }; 41 | 42 | Collection.prototype.toJSON = function() { 43 | return this.items.map(function(model) { 44 | return model.toJSON(); 45 | }); 46 | }; 47 | 48 | Collection.prototype._updateLength = function() { 49 | this.length = this.items.length; 50 | }; 51 | 52 | events(Collection.prototype); -------------------------------------------------------------------------------- /docs/removing-and-destroying.md: -------------------------------------------------------------------------------- 1 | ## Destroying and removing 2 | 3 | Eventually a module has to be destroyed. To do this you simply call `mymodule.destroy()`. This does more than just run your module's defined `destroy` logic. When destroy is called on a module it recursively calls `.destroy()` on any descendant modules (from the bottom up). 4 | 5 | **Destroy does the following:** 6 | 7 | 1. It runs `.teardown()` to undo any setup logic. 8 | 2. It removes the module from any module it may be nested inside. 9 | 3. It removes the module from the DOM. 10 | 3. It unmounts the module from element. 11 | 4. It runs your module's custom destroy logic. 12 | 5. It fires a `destroy` event hook. 13 | 6. It sets `module.destroyed` to `true` (useful for checking for destroyed views). 14 | 7. It calls `module.off()` to unbind all event listeners. 15 | 16 | **NOTE:** Once a module module has been destroyed, it cannot be brought back to life. Like `initialize`, `destroy` can only happen once in the lifetime of a module module. 17 | 18 | #### Just removing 19 | 20 | You may just want to remove a module from it's current context and drop it somewhere else, without actually destroying it. In this case you will want to use `.remove()`. 21 | 22 | Remove will: 23 | 24 | - Remove a module from from any module it may be nested inside. 25 | - Remove the module's element (`.el`) from the DOM (if applicable), unless the called with `{ fromDOM: false }`. 26 | -------------------------------------------------------------------------------- /GruntFile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | // Project configuration. 4 | grunt.initConfig({ 5 | pkg: grunt.file.readJSON('package.json'), 6 | 7 | browserify: { 8 | build: { 9 | src: 'lib/index.js', 10 | dest: 'build/<%= pkg.name %>.js' 11 | }, 12 | test: { 13 | src: 'coverage/lib/index.js', 14 | dest: 'coverage/build/<%= pkg.name %>.js' 15 | }, 16 | options: { 17 | standalone: '<%= pkg.name %>' 18 | } 19 | }, 20 | 21 | run: { 22 | test: { 23 | cmd: 'npm', 24 | args: [ 'test' ], 25 | } 26 | }, 27 | 28 | uglify: { 29 | build: { 30 | src: 'build/<%= pkg.name %>.js', 31 | dest: 'build/<%= pkg.name %>.min.js' 32 | } 33 | }, 34 | 35 | watch: { 36 | scripts: { 37 | files: ['lib/**/*.js'], 38 | tasks: ['browserify'] 39 | } 40 | }, 41 | 42 | instrument: { 43 | files: 'lib/**/*.js', 44 | options: { 45 | basePath: 'coverage/' 46 | } 47 | }, 48 | }); 49 | 50 | grunt.loadNpmTasks('grunt-browserify'); 51 | grunt.loadNpmTasks('grunt-contrib-uglify'); 52 | grunt.loadNpmTasks('grunt-contrib-watch'); 53 | grunt.loadNpmTasks('grunt-run'); 54 | 55 | // Default task. 56 | grunt.registerTask('default', ['browserify:build', 'uglify']); 57 | grunt.registerTask('test', ['browserify:test', 'run:test']); 58 | }; 59 | -------------------------------------------------------------------------------- /docs/module-instantiation.md: -------------------------------------------------------------------------------- 1 | ## Instantiation 2 | 3 | There are two ways to instantiate a FruitMachine module: 4 | 5 | ##### Explicit 6 | 7 | ```js 8 | var apple = new Apple(); 9 | ``` 10 | 11 | ##### Lazy 12 | 13 | ```js 14 | var apple = fruitmachine({ module: 'apple' }); 15 | ``` 16 | 17 | Use *Explicit* instantiation over *Lazy* instantiation whenever possible. The *Lazy* instantiation option exists so that you are able to predefine page layouts in JSON form and pass them into the `fruitmachine()` factory method. 18 | 19 | When instantiating 'lazily' *FruitMachine* looks at the `module` property and attempts to map it to a module you have defined using [fruitmachine.define()](defining-modules.md). If a match is found, it will `Explictly` instantiate that module with the options you originally passed. 20 | 21 | ### Options 22 | 23 | - `id {String}` Your unique id for this View module 24 | - `module {String}` The module type (only use if using 'Lazy' instantiation) 25 | - `children {Array}` An array of child views to instantiate (can be lazy JSON or view instances) 26 | - `model {Object}` A data model object that will be accessible in your template 27 | - `helpers {Array}` An array of helper functions to be called on instantiation 28 | - `classes {Array}` Classes to be added to the root element 29 | - `template {Function}` A template function that will return HTML (will any existing template) 30 | - `tag {String}` The tag to use for the root element (defaults to 'div') 31 | -------------------------------------------------------------------------------- /examples/big-collection/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Big Collection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
    28 | 29 | 30 | -------------------------------------------------------------------------------- /test/tests/module.toJSON.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#toJSON()', function() { 3 | 4 | test("Should return an fmid", function() { 5 | var apple = new Apple(); 6 | var json = apple.toJSON(); 7 | 8 | expect(json.fmid).toBeTruthy(); 9 | }); 10 | 11 | test("Should fire `tojson` event", function() { 12 | var apple = new Apple(); 13 | var spy = jest.fn(); 14 | 15 | apple.on('tojson', spy); 16 | apple.toJSON(); 17 | 18 | expect(spy).toHaveBeenCalled(); 19 | }); 20 | 21 | test("Should be able to manipulate json output via `tojson` event", function() { 22 | var apple = new Apple(); 23 | 24 | apple.on('tojson', function(json) { 25 | json.test = 'data'; 26 | }); 27 | 28 | var json = apple.toJSON(); 29 | 30 | expect(json.test).toEqual('data'); 31 | }); 32 | 33 | test("Should be able to inflate the output", function() { 34 | var sandbox = helpers.createSandbox(); 35 | var layout = new Layout({ 36 | children: { 37 | 1: { module: 'apple' } 38 | } 39 | }); 40 | 41 | layout 42 | .render() 43 | .inject(sandbox) 44 | .setup(); 45 | 46 | var layoutEl = layout.el; 47 | var appleEl = layout.module('apple').el; 48 | var json = layout.toJSON(); 49 | var inflated = fruitmachine(json); 50 | 51 | inflated.setup(); 52 | 53 | var layoutElInflated = inflated.el; 54 | var appleElInflated = inflated.module('apple').el; 55 | 56 | expect(layoutEl).toEqual(layoutElInflated); 57 | expect(appleEl).toEqual(appleElInflated); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /lib/define.js: -------------------------------------------------------------------------------- 1 | 2 | /*jslint browser:true, node:true, laxbreak:true*/ 3 | 4 | 'use strict'; 5 | 6 | module.exports = function(fm) { 7 | 8 | /** 9 | * Defines a module. 10 | * 11 | * Options: 12 | * 13 | * - `name {String}` the name of the module 14 | * - `tag {String}` the tagName to use for the root element 15 | * - `classes {Array}` a list of classes to add to the root element 16 | * - `template {Function}` the template function to use when rendering 17 | * - `helpers {Array}` a list of helpers to apply to the module 18 | * - `initialize {Function}` custom logic to run when module instance created 19 | * - `setup {Function}` custom logic to run when `.setup()` is called (directly or indirectly) 20 | * - `teardown {Function}` custom logic to unbind/undo anything setup introduced (called on `.destroy()` and sometimes on `.setup()` to avoid double binding events) 21 | * - `destroy {Function}` logic to permanently destroy all references 22 | * 23 | * @param {Object|View} props 24 | * @return {View} 25 | * @public true 26 | */ 27 | return function(props) { 28 | var Module = ('object' === typeof props) 29 | ? fm.Module.extend(props) 30 | : props; 31 | 32 | // Allow modules to be named 33 | // via 'name:' or 'module:' 34 | var proto = Module.prototype; 35 | var name = proto.name || proto._module; 36 | 37 | // Store the module by module type 38 | // so that module can be referred to 39 | // by just a string in layout definitions 40 | if (name) fm.modules[name] = Module; 41 | 42 | return Module; 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /lib/fruitmachine.js: -------------------------------------------------------------------------------- 1 | /*jslint browser:true, node:true*/ 2 | 3 | /** 4 | * FruitMachine 5 | * 6 | * Renders layouts/modules from a basic layout definition. 7 | * If views require custom interactions devs can extend 8 | * the basic functionality. 9 | * 10 | * @version 0.3.3 11 | * @copyright The Financial Times Limited [All Rights Reserved] 12 | * @author Wilson Page 13 | */ 14 | 15 | 'use strict'; 16 | 17 | /** 18 | * Module Dependencies 19 | */ 20 | 21 | var mod = require('./module'); 22 | var define = require('./define'); 23 | var utils = require('utils'); 24 | var events = require('evt'); 25 | 26 | /** 27 | * Creates a fruitmachine 28 | * 29 | * Options: 30 | * 31 | * - `Model` A model constructor to use (must have `.toJSON()`) 32 | * 33 | * @param {Object} options 34 | */ 35 | module.exports = function(options) { 36 | 37 | /** 38 | * Shortcut method for 39 | * creating lazy views. 40 | * 41 | * @param {Object} options 42 | * @return {Module} 43 | */ 44 | function fm(options) { 45 | var Module = fm.modules[options.module]; 46 | if (Module) { 47 | return new Module(options); 48 | } 49 | throw new Error("Unable to find module '" + options.module + "'"); 50 | } 51 | 52 | fm.create = module.exports; 53 | fm.Model = options.Model; 54 | fm.Events = events; 55 | fm.Module = mod(fm); 56 | fm.define = define(fm); 57 | fm.util = utils; 58 | fm.modules = {}; 59 | fm.config = { 60 | templateIterator: 'children', 61 | templateInstance: 'child' 62 | }; 63 | 64 | // Mixin events and return 65 | return events(fm); 66 | }; 67 | -------------------------------------------------------------------------------- /test/tests/module.teardown.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#teardown()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Teardown should recurse.", function() { 10 | var teardown1 = jest.spyOn(viewToTest, 'teardown'); 11 | var teardown2 = jest.spyOn(viewToTest.module('orange'), 'teardown'); 12 | 13 | viewToTest 14 | .render() 15 | .setup() 16 | .teardown(); 17 | 18 | expect(teardown1).toHaveBeenCalled(); 19 | expect(teardown2).toHaveBeenCalled(); 20 | }); 21 | 22 | test("Should not recurse if used with the `shallow` option.", function() { 23 | var teardown1 = jest.spyOn(viewToTest, 'teardown'); 24 | var teardown2 = jest.spyOn(viewToTest.module('orange'), 'teardown'); 25 | var _teardown2 = jest.spyOn(viewToTest.module('orange'), '_teardown'); 26 | 27 | viewToTest 28 | .render() 29 | .setup() 30 | .teardown({ shallow: true }); 31 | 32 | expect(teardown1).toHaveBeenCalled(); 33 | expect(teardown2).not.toHaveBeenCalled(); 34 | expect(_teardown2).not.toHaveBeenCalled(); 35 | }); 36 | 37 | test("Should not run custom teardown logic if the view has not been setup", function() { 38 | var teardown = jest.spyOn(viewToTest, 'teardown'); 39 | var _teardown = jest.spyOn(viewToTest, '_teardown'); 40 | 41 | viewToTest 42 | .render() 43 | .teardown(); 44 | 45 | expect(teardown).toHaveBeenCalled(); 46 | expect(_teardown).not.toHaveBeenCalled(); 47 | }); 48 | 49 | afterEach(function() { 50 | helpers.destroyView(); 51 | viewToTest = null; 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /examples/article-viewer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Article Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/article-viewer-alt/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Article Viewer (alt) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    32 | 33 | 34 | -------------------------------------------------------------------------------- /test/tests/module._setEl.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#_setEl()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should replace the element in context if it has a context", function() { 10 | var layout = fruitmachine({ 11 | module: 'layout', 12 | children: { 13 | 1: { 14 | module: 'apple', 15 | children: { 16 | 1: { 17 | module: 'orange' 18 | } 19 | } 20 | } 21 | } 22 | }); 23 | 24 | layout.render(); 25 | 26 | var apple = layout.module('apple'); 27 | var orange = layout.module('orange'); 28 | var replacement = document.createElement('div'); 29 | 30 | orange._setEl(replacement); 31 | 32 | expect(replacement.parentNode).toBe(apple.el); 33 | }); 34 | 35 | test("Should call unmount if replacing the element", function() { 36 | var layoutSpy = jest.fn(); 37 | var appleSpy = jest.fn(); 38 | var orangeSpy = jest.fn(); 39 | var pearSpy = jest.fn(); 40 | 41 | viewToTest.on('unmount', layoutSpy); 42 | viewToTest.module('apple').on('unmount', appleSpy); 43 | viewToTest.module('orange').on('unmount', orangeSpy); 44 | viewToTest.module('pear').on('unmount', pearSpy); 45 | 46 | viewToTest.render().inject(sandbox); 47 | viewToTest._setEl(document.createElement('div')); 48 | 49 | expect(layoutSpy).toHaveBeenCalled(); 50 | expect(appleSpy).toHaveBeenCalled(); 51 | expect(orangeSpy).toHaveBeenCalled(); 52 | expect(pearSpy).toHaveBeenCalled(); 53 | }); 54 | 55 | afterEach(function() { 56 | helpers.destroyView(); 57 | viewToTest = null; 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/tests/module._getEl.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#_getEl()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should return undefined if not rendered", function() { 10 | var el = viewToTest._getEl(); 11 | expect(el).toBeUndefined(); 12 | }); 13 | 14 | test("Should return an element if rendered directly", function() { 15 | var el; 16 | viewToTest.render(); 17 | el = viewToTest._getEl(); 18 | expect(el).toBeDefined(); 19 | }); 20 | 21 | test("Should return the view element if the view was rendered indirectly", function() { 22 | var spy = jest.spyOn(fruitmachine.util, 'byId'); 23 | var el; 24 | 25 | viewToTest.render(); 26 | el = viewToTest.module('orange')._getEl(); 27 | 28 | expect(el).toBeDefined(); 29 | expect(spy).toHaveBeenCalled(); 30 | 31 | fruitmachine.util.byId.mockRestore(); 32 | }); 33 | 34 | test("Should return a different element if parent is re-rendered in DOM", function() { 35 | var el1, el2; 36 | 37 | viewToTest 38 | .render() 39 | .inject(sandbox); 40 | 41 | el1 = viewToTest.module('orange')._getEl(); 42 | viewToTest.render(); 43 | el2 = viewToTest.module('orange')._getEl(); 44 | 45 | expect(el1).not.toBe(el2); 46 | }); 47 | 48 | test("Should return a different element if parent is re-rendered in memory", function() { 49 | var el1, el2; 50 | 51 | viewToTest.render(); 52 | 53 | el1 = viewToTest.module('orange')._getEl(); 54 | viewToTest.render(); 55 | el2 = viewToTest.module('orange')._getEl(); 56 | 57 | expect(el1).not.toBe(el2); 58 | }); 59 | 60 | afterEach(function() { 61 | helpers.destroyView(); 62 | viewToTest = null; 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /docs/server-side-rendering.md: -------------------------------------------------------------------------------- 1 | ## Server-side rendering 2 | 3 | FruitMachine is able to work in exactly the same way on a Node server as on the client. Our [Express example](../examples/express) demonstrates this very simply. There are two items we must send down from the server: the rendered HTML embedded in the page, and a JSON representation of the module. Once we have those two parts we are able to 'inflate' the view, just as though it was rendered on the client. Here's how I might work with a fruitmachine layout on the server: 4 | 5 | #### Define a module 6 | 7 | ```js 8 | module.exports = fruitmachine.define({ 9 | name: 'apple', 10 | template: appleTemplateFunction, 11 | setup: function() { 12 | alert("I'm alive!"); 13 | } 14 | }); 15 | ``` 16 | 17 | #### Server 18 | 19 | We create an instance of our Apple view, turn it to HTML and extract a json representation of it. We then send a string as the response that contains both parts. 20 | 21 | ```js 22 | var Apple = require('./apple'); 23 | 24 | // Express style route handler 25 | app.get('/', function(req, res) { 26 | var apple = new Apple(); 27 | var html = apple.toHTML(); 28 | var json = apple.toJSON(); 29 | 30 | json = JSON.stringify(json); 31 | 32 | // Imagine this response is also 33 | // wrapped in usual document boilerplate 34 | // with FruitMachine on the page :) 35 | res.send('' + html); 36 | }); 37 | ``` 38 | 39 | #### Client 40 | 41 | Once on the client we can pass the JSON part directly into the `fruitmachine()` method to return a FruitMachine module. Calling setup fetches the module's element from the DOM and runs any custom setup logic (in this case our `alert` message). 42 | 43 | ```js 44 | var module = fruitmachine(window.json); 45 | 46 | module.setup(); 47 | //=> alerts "I'm alive!" 48 | 49 | module.el; 50 | //=> "[object HTMLDivElement]" 51 | ``` -------------------------------------------------------------------------------- /test/tests/module.toHTML.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#toHTML()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should return a string", function() { 10 | var html = viewToTest.toHTML(); 11 | expect('string' === typeof html).toBe(true); 12 | }); 13 | 14 | test("Should fire `before tohtml event`", function() { 15 | var spy = jest.fn(); 16 | viewToTest.on('before tohtml', spy); 17 | var html = viewToTest.toHTML(); 18 | expect('string' === typeof html).toBe(true); 19 | expect(spy).toHaveBeenCalled(); 20 | }); 21 | 22 | test("Should print the child html into the corresponding slot", function() { 23 | var apple = new Apple({ slot: 1 }); 24 | var layout = new Layout({ 25 | children: [apple] 26 | }); 27 | 28 | var appleHtml = apple.toHTML(); 29 | var layoutHtml = layout.toHTML(); 30 | 31 | expect(layoutHtml.indexOf(appleHtml)).toBeGreaterThan(-1); 32 | }); 33 | 34 | test("Should print the child html by id if no slot is found (backwards compatable)", function() { 35 | var apple = new Apple({ id: 1 }); 36 | var layout = new Layout({ 37 | children: [apple] 38 | }); 39 | 40 | var appleHtml = apple.toHTML(); 41 | var layoutHtml = layout.toHTML(); 42 | 43 | expect(layoutHtml.indexOf(appleHtml)).toBeGreaterThan(-1); 44 | }); 45 | 46 | test("Should fallback to printing children by id if no slot is present", function() { 47 | var layout = new Layout({ 48 | children: [ 49 | { 50 | module: 'apple', 51 | id: 1 52 | } 53 | ] 54 | }); 55 | 56 | layout.render(); 57 | 58 | expect(layout.el.innerHTML.indexOf('apple')).toBeGreaterThan(-1); 59 | }); 60 | 61 | afterEach(function() { 62 | helpers.destroyView(); 63 | viewToTest = null; 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /docs/layout-assembly.md: -------------------------------------------------------------------------------- 1 | ## View Assembly 2 | 3 | When View modules are nested, a hierarchical view structure is formed. For flexibility, *FruitMachine* allows nested views to be assembled in a variety of ways. 4 | 5 | #### Manual 6 | 7 | ```js 8 | var layout = new Layout(); 9 | var apple = new Apple(); 10 | var orange = new Orange(); 11 | 12 | apple.add(orange); 13 | layout.add(apple); 14 | 15 | layout.children.length; //=> 1 16 | apple.children.length; //=> 1 17 | orange.children.length; //=> 0 18 | ``` 19 | 20 | #### Lazy 21 | 22 | ```js 23 | var layout = new Layout({ 24 | children: { 25 | 1: { 26 | module: 'apple', 27 | children: { 28 | 1: { 29 | module: 'orange' 30 | } 31 | } 32 | } 33 | } 34 | }); 35 | 36 | layout.children.length; //=> 1 37 | apple.children.length; //=> 1 38 | orange.children.length; //=> 0 39 | ``` 40 | 41 | #### Super lazy 42 | 43 | ```js 44 | var layout = fruitmachine({ 45 | module: 'layout', 46 | children: { 47 | 1: { 48 | module: 'apple', 49 | children: { 50 | 1: { 51 | module: 'orange' 52 | } 53 | } 54 | } 55 | } 56 | }); 57 | 58 | layout.children.length; //=> 1 59 | apple.children.length; //=> 1 60 | orange.children.length; //=> 0 61 | ``` 62 | 63 | #### Removing modules 64 | 65 | Sometimes you may wish to add or replace modules before the layout is rendered. This is a good use case for `.remove()`. 66 | 67 | ```js 68 | var layout = fruitmachine({ 69 | module: 'layout', 70 | children: [ 71 | 1: { 72 | module: 'apple', 73 | children: { 74 | 1: { 75 | module: 'orange' 76 | } 77 | } 78 | } 79 | ] 80 | }); 81 | 82 | var apple = layout.module('apple'); 83 | var orange = layout.module('orange'); 84 | var banana = new Banana(); 85 | 86 | apple 87 | .remove(orange) 88 | .add(banana, { slot: 1 }); 89 | ``` 90 | -------------------------------------------------------------------------------- /examples/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: TODO 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
    35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/slots.md: -------------------------------------------------------------------------------- 1 | ## Slots 2 | 3 | *FruitMachine* uses the concept of 'slots' to place child modules inside a parent module. 4 | 5 | #### Defining slots 6 | 7 | Slots are defined in a view module's template and can be named what ever you like. 8 | 9 | **my-view.mustache** 10 | 11 | ```html 12 |
    {{{1}}}
    13 |
    {{{foo}}}
    14 | ``` 15 | 16 | In the template above we have defined two slots: '1' and 'foo'. 17 | 18 | #### Placing child modules into slots 19 | 20 | For flexibility there are several way to define which slot a child view should sit in. All the following examples are equivalent. 21 | 22 | **Example 1:** 23 | 24 | ```js 25 | var myView = new MyView(); 26 | var apple = new Apple(); 27 | var orange = new Orange(); 28 | 29 | myView 30 | .add(apple, { slot: 1 }) 31 | .add(orange, { slot: 'foo' }); 32 | ``` 33 | 34 | **Example 2:** 35 | 36 | ```js 37 | var myView = new MyView(); 38 | var apple = new Apple(); 39 | var orange = new Orange(); 40 | 41 | myView 42 | .add(apple, 1) 43 | .add(orange, 'foo'); 44 | ``` 45 | 46 | **Example 3:** 47 | 48 | ```js 49 | var myView = new MyView({ 50 | children: { 51 | 1: { 52 | module: 'apple' 53 | }, 54 | foo: { 55 | module: 'orange' 56 | } 57 | } 58 | }); 59 | ``` 60 | 61 | **Example 4:** 62 | 63 | ```js 64 | var myView = new MyView(); 65 | var apple = new Apple({ slot: 1 }); 66 | var orange = new Orange({ slot: 'foo' }); 67 | 68 | myView 69 | .add(apple) 70 | .add(orange); 71 | ``` 72 | 73 | **Example 5:** 74 | 75 | ```js 76 | var myView = new MyView({ 77 | children: [ 78 | { 79 | slot: 1, 80 | module: 'apple' 81 | }, 82 | { 83 | slot: 'foo', 84 | module: 'orange' 85 | } 86 | ] 87 | }); 88 | ``` 89 | 90 | #### The resulting output 91 | 92 | ```html 93 |
    ...
    94 |
    ...
    95 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fruitmachine", 3 | "title": "FruitMachine", 4 | "description": "A lightweight component layout engine for client and server.", 5 | "version": "1.2.2", 6 | "homepage": "https://github.com/wilsonpage/fruitmachine", 7 | "author": { 8 | "name": "Wilson Page", 9 | "email": "wilsonpage@me.com", 10 | "github": "wilsonpage" 11 | }, 12 | "organization": "The Financial Times Limited", 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/ftlabs/fruitmachine.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/ftlabs/fruitmachine/issues" 19 | }, 20 | "licenses": [ 21 | { 22 | "type": "MIT", 23 | "url": "https://github.com/ftlabs/fruitmachine/blob/master/LICENSE" 24 | } 25 | ], 26 | "main": "lib/index", 27 | "engines": { 28 | "node": "*" 29 | }, 30 | "scripts": { 31 | "test": "jest --coverage", 32 | "coveralls": "cat coverage/lcov.info | coveralls", 33 | "prepublish": "grunt" 34 | }, 35 | "dependencies": { 36 | "evt": "github:financial-times/ft-app-evt", 37 | "extend": "github:financial-times/ft-app-extend", 38 | "model": "github:financial-times/ft-app-model", 39 | "utils": "github:financial-times/ft-app-utils" 40 | }, 41 | "devDependencies": { 42 | "backbone": "^1.2.0", 43 | "coveralls": "^3.0.2", 44 | "grunt": "^1.0.3", 45 | "grunt-browserify": "^5.3.0", 46 | "grunt-cli": "^1.3.2", 47 | "grunt-contrib-uglify": "^4.0.0", 48 | "grunt-contrib-watch": "^1.1.0", 49 | "grunt-run": "^0.8.1", 50 | "hogan.js": "^3.0.0", 51 | "jest": "^23.6.0" 52 | }, 53 | "keywords": [], 54 | "contributors": [ 55 | { 56 | "name": "Wilson Page", 57 | "email": "wilsonpage@me.com", 58 | "github": "wilsonpage" 59 | }, 60 | { 61 | "name": "Matt Andrews", 62 | "email": "matt@mattandre.ws", 63 | "github": "matthew-andrews" 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /examples/collection/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example: Collection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
    36 | 37 |
    38 | 39 | 40 | -------------------------------------------------------------------------------- /test/tests/module.appendTo.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#appendTo()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should return the view for a fluent interface.", function() { 10 | var sandbox = document.createElement('div'), 11 | sandbox2 = document.createElement('div'), 12 | existing = document.createElement('div'); 13 | 14 | sandbox2.appendChild(existing); 15 | 16 | expect(viewToTest.render().appendTo(sandbox)).toBe(viewToTest); 17 | expect(viewToTest.render().insertBefore(sandbox2, existing)).toBe(viewToTest); 18 | }); 19 | 20 | test("Should append the view element as a child of the given element.", function() { 21 | var sandbox = document.createElement('div'); 22 | 23 | viewToTest 24 | .render() 25 | .appendTo(sandbox); 26 | 27 | expect(viewToTest.el).toBe(sandbox.firstElementChild); 28 | }); 29 | 30 | test("Should not destroy existing element contents.", function() { 31 | var sandbox = document.createElement('div'), 32 | existing = document.createElement('div'); 33 | 34 | sandbox.appendChild(existing); 35 | 36 | viewToTest 37 | .render() 38 | .appendTo(sandbox); 39 | 40 | expect(existing).toBe(sandbox.firstElementChild); 41 | expect(viewToTest.el).toBe(sandbox.lastElementChild); 42 | }); 43 | 44 | test("Should insert before specified element.", function() { 45 | var sandbox = document.createElement('div'), 46 | existing1 = document.createElement('div'), 47 | existing2 = document.createElement('div'); 48 | 49 | sandbox.appendChild(existing1); 50 | sandbox.appendChild(existing2); 51 | 52 | viewToTest 53 | .render() 54 | .insertBefore(sandbox, existing2); 55 | 56 | expect(existing1).toBe(sandbox.firstElementChild); 57 | expect(viewToTest.el).toBe(existing1.nextSibling); 58 | expect(existing2).toBe(viewToTest.el.nextSibling); 59 | }); 60 | 61 | afterEach(function() { 62 | helpers.emptySandbox(); 63 | helpers.destroyView(); 64 | viewToTest = null; 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/tests/module.fire.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#fire()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should run on callbacks registered on the view", function() { 10 | var spy = jest.fn(); 11 | 12 | viewToTest.on('testevent', spy); 13 | viewToTest.fire('testevent'); 14 | 15 | expect(spy).toHaveBeenCalledTimes(1); 16 | }); 17 | 18 | test("Events should bubble by default", function() { 19 | var spy = jest.fn(); 20 | var child = viewToTest.module('orange'); 21 | 22 | viewToTest.on('childtestevent', spy); 23 | child.fire('childtestevent'); 24 | 25 | expect(spy).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | test("Calling event.stopPropagation() should stop bubbling", function() { 29 | var spy = jest.fn(); 30 | var child = viewToTest.module('orange'); 31 | 32 | viewToTest.on('childtestevent', spy); 33 | child.on('childtestevent', function(){ 34 | this.event.stopPropagation(); 35 | }); 36 | 37 | child.fire('childtestevent'); 38 | expect(spy).toHaveBeenCalledTimes(0); 39 | }); 40 | 41 | test("Should pass arguments to the callback", function() { 42 | var spy = jest.fn(); 43 | var arg1 = 'arg1'; 44 | var arg2 = 'arg2'; 45 | var arg3 = 'arg3'; 46 | 47 | viewToTest.on('childtestevent', spy); 48 | viewToTest.fire('childtestevent', arg1, arg2, arg3); 49 | 50 | expect(spy).toHaveBeenCalledWith(arg1, arg2, arg3); 51 | }); 52 | 53 | test("Should allow multiple events to be in progress on the same view", function() { 54 | var layout = viewToTest; 55 | var apple = layout.module('apple'); 56 | var event; 57 | 58 | apple.on('testevent1', function() { 59 | event = this.event; 60 | expect(this.event.target).toBe(apple); 61 | }); 62 | 63 | apple.on('testevent1', function() { 64 | expect(event).toBe(this.event); 65 | apple.fire('testevent2'); 66 | }); 67 | 68 | apple.on('testevent2', function() { 69 | expect(event).not.toBe(this.event); 70 | expect(this.event.target).toBe(apple); 71 | }); 72 | 73 | apple.fire('testevent1'); 74 | }); 75 | 76 | afterEach(function() { 77 | helpers.destroyView(); 78 | viewToTest = null; 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/tests/helpers.js: -------------------------------------------------------------------------------- 1 | 2 | describe('fruitmachine#helpers()', function() { 3 | var testHelper; 4 | 5 | beforeEach(function() { 6 | testHelper = function(view) { 7 | view.on('before initialize', testHelper.beforeInitialize); 8 | view.on('initialize', testHelper.initialize); 9 | view.on('setup', testHelper.setup); 10 | view.on('teardown', testHelper.teardown); 11 | view.on('destroy', testHelper.destroy); 12 | }; 13 | 14 | testHelper.beforeInitialize = jest.fn(); 15 | testHelper.initialize = jest.fn(); 16 | testHelper.setup = jest.fn(); 17 | testHelper.teardown = jest.fn(); 18 | testHelper.destroy = jest.fn(); 19 | }); 20 | 21 | test("helpers `before initialize` and `initialize` should have been called, in that order", function() { 22 | var view = fruitmachine({ 23 | module: 'apple', 24 | helpers: [testHelper] 25 | }); 26 | 27 | expect(testHelper.initialize).toHaveBeenCalled(); 28 | expect(testHelper.beforeInitialize).toHaveBeenCalled(); 29 | expect(testHelper.initialize.mock.invocationCallOrder).toEqual([2]); 30 | expect(testHelper.beforeInitialize.mock.invocationCallOrder).toEqual([1]); 31 | expect(testHelper.setup).toHaveBeenCalledTimes(0); 32 | expect(testHelper.teardown).toHaveBeenCalledTimes(0); 33 | expect(testHelper.destroy).toHaveBeenCalledTimes(0); 34 | }); 35 | 36 | test("helper `setup` should have been called", function() { 37 | var view = fruitmachine({ 38 | module: 'apple', 39 | helpers: [testHelper] 40 | }); 41 | 42 | expect(testHelper.setup).toHaveBeenCalledTimes(0); 43 | 44 | view 45 | .render() 46 | .inject(sandbox) 47 | .setup(); 48 | 49 | expect(testHelper.setup).toHaveBeenCalledTimes(1); 50 | }); 51 | 52 | test("helper `teardown` and `destroy` should have been called", function() { 53 | var view = fruitmachine({ 54 | module: 'apple', 55 | helpers: [testHelper] 56 | }); 57 | 58 | view 59 | .render() 60 | .inject(sandbox) 61 | .setup() 62 | .teardown() 63 | .destroy(); 64 | 65 | expect(testHelper.teardown).toHaveBeenCalled(); 66 | expect(testHelper.destroy).toHaveBeenCalled(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /docs/module-el.md: -------------------------------------------------------------------------------- 1 | ## The Module's Element 2 | 3 | Each module has a 'root' element (`myModule.el`). This is the single element that wraps the contents of a module. It is your handle on the module once it has been [injected](view-injection.md) into the DOM. You may be familiar with the `.el` concept from [Backbone](http://backbonejs.org/). In *FruitMachine* it is similar, but due to the 'DOM free' nested rendering techniques used, the `myModule.el` is not always accessible. 4 | 5 | In FruitMachine the `.el` property of a module is populated when `view.render()` is called. 6 | 7 | **NOTE:** As a safety measure we do not setup modules when a module's element could not be found. This means that `myModule.el` related setup logic wont error when `myModule.el` is `undefined`. 8 | 9 | #### Some examples 10 | 11 | 1.1. After render `module.el` will be defined 12 | 13 | ```js 14 | var apple = new Apple(); 15 | var orange = new Orange(); 16 | 17 | apple 18 | .add(orange) 19 | .render(); 20 | 21 | apple.el 22 | //=> "[object HTMLDivElement]" 23 | 24 | orange.el 25 | //=> "[object HTMLDivElement]" 26 | ``` 27 | 28 | 1.2. Without calling `.render()` no module elements are set. 29 | 30 | ```js 31 | var apple = new Apple(); 32 | var orange = new Orange(); 33 | 34 | apple.add(orange); 35 | 36 | apple.el 37 | //=> undefined 38 | 39 | orange.el 40 | //=> undefined 41 | ``` 42 | 43 | ### FAQ 44 | 45 | #### Why is my module.el property undefined after .render()? 46 | 47 | The child module markup has failed to template into the parent module correctly. Check your child ids and parent markup to check they match up. See [template markup](view-template-markup.md). 48 | 49 | #### How are module root elements found? 50 | 51 | Internally each module has a private unique id (`myModule._fmid`). When the root element is templated, the html `id` attribute value is set to the `myModule._fmid`. When `.render()` is called on a module the HTML is templated and turned into a 'real' element in memory. We store this element and then search for descendant elements by id using `querySelector`. Server-side rendered modules being inflated on the client pick up their root element from the DOM when `.setup()` is called using `document.getElementById(module._fmid)` (super fast). 52 | -------------------------------------------------------------------------------- /test/tests/define.js: -------------------------------------------------------------------------------- 1 | 2 | describe('fruitmachine.define()', function() { 3 | test("Should store the module in fruitmachine.store under module type", function() { 4 | fruitmachine.define({ module: 'my-module-1' }); 5 | expect(fruitmachine.modules['my-module-1']).toBeDefined(); 6 | }); 7 | 8 | test("Should return an instantiable constructor", function() { 9 | var View = fruitmachine.define({ module: 'new-module-1' }); 10 | var view = new View(); 11 | 12 | expect(view._fmid).toBeDefined(); 13 | expect(view.module()).toBe('new-module-1'); 14 | }); 15 | 16 | test("Should find module from internal module store if a `module` parameter is passed", function() { 17 | var apple = new fruitmachine({ module: 'apple' }); 18 | 19 | expect(apple.module()).toBe('apple'); 20 | expect(apple.template).toBeDefined(); 21 | }); 22 | 23 | test("Not defining reserved methods should not rewrite keys with prefixed with '_'", function() { 24 | var View = fruitmachine.define({ 25 | module: 'foobar', 26 | }); 27 | 28 | expect(View.prototype._setup).toBeUndefined(); 29 | }); 30 | 31 | test("Should be able to accept a Module class, so that a Module can be defined from extended modules", function() { 32 | var initialize1 = jest.fn(); 33 | var initialize2 = jest.fn(); 34 | var setup1 = jest.fn(); 35 | var setup2 = jest.fn(); 36 | 37 | var View1 = fruitmachine.define({ 38 | module: 'new-module-1', 39 | random: 'prop', 40 | template: helpers.templates.apple, 41 | initialize: initialize1, 42 | setup: setup1 43 | }); 44 | 45 | var View2 = fruitmachine.define(View1.extend({ 46 | module: 'new-module-2', 47 | random: 'different', 48 | initialize: initialize2, 49 | setup: setup2 50 | })); 51 | 52 | var view1 = new View1() 53 | .render() 54 | .setup(); 55 | 56 | var view2 = new View2() 57 | .render() 58 | .setup(); 59 | 60 | 61 | expect(View1.prototype._module).toBe('new-module-1'); 62 | expect(View2.prototype._module).toBe('new-module-2'); 63 | expect(View2.prototype.random).toBe('different'); 64 | expect(initialize1.mock.calls.length).toBe(1); 65 | expect(initialize2.mock.calls.length).toBe(1); 66 | expect(setup1.mock.calls.length).toBe(1); 67 | expect(setup2.mock.calls.length).toBe(1); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /docs/defining-modules.md: -------------------------------------------------------------------------------- 1 | # Defining Modules 2 | 3 | ```js 4 | var Apple = fruitmachine.define({ 5 | name: 'apple', 6 | template: templateFunction, 7 | tag: 'section', 8 | classes: ['class-1', 'class-2'], 9 | 10 | // Event callbacks (optional) 11 | initialize: function(options){}, 12 | setup: function(){}, 13 | mount: function(){}, 14 | teardown: function(){}, 15 | destroy: function(){} 16 | }); 17 | ``` 18 | 19 | Define does two things: 20 | 21 | - It registers a module internally for [Lazy](module-instantiation.md#lazy) module instantiation 22 | - It returns a constructor that can be [Explicitly](module-instantiation.md#explicit) instantiated. 23 | 24 | Internally `define` extends the default `fruitmachine.Module.prototype` with the parameters you define. Many of these parameters can be overwritten in the options passed to the constructor on a per instance basis. It is important you don't declare any parameters that conflict with `fruitmachine.Module.prototype` core API (check the [source]() if you are unsure). 25 | 26 | ### Options 27 | 28 | - `name {String}` Your name for this module. 29 | - `template {Function}` A function that will return the module's html (we like [Hogan](http://twitter.github.com/hogan.js/)) 30 | - `tag {String}` The html tag to use on the root element (defaults to 'div') *(optional)* 31 | - `classes {Array}` A list of classes to add to the root element. *(optional)* 32 | - `initialize {Function}` Define a function to run when the module is first instantiated (only ever runs once) *(optional)* 33 | - `setup {Function}` A function to be run every time `Module#setup()` is called. You can safely assume the presence of `this.el` at this point; however, this element is not guaranteed to exist or be associated with the module in the future, for example if the module's parent is re-rendered. *(optional)* 34 | - `mount {Function}` A function to be run every time `Module#mount()` is called, i.e. when the module has been associated with a new DOM element. Should be used to bind any DOM event listeners. *(optional)* 35 | - `teardown {Function}` A function to be run when `Module#teardown()` or `Module#destroy()` is called. `teardown` will also run if you attempt to setup an already 'setup' module. 36 | - `destroy {Function}` Run when `Module#destroy()` is called (will only ever run once) *(optional)* 37 | -------------------------------------------------------------------------------- /docs/module-helpers.md: -------------------------------------------------------------------------------- 1 | ## Helpers 2 | 3 | Helpers are small reusable plug-ins that you can write to add extra features to a View module ([working example](http://ftlabs.github.io/fruitmachine/examples/helpers)). 4 | 5 | ### Defining helpers 6 | 7 | A helper is simply a function accepting the View module instance as the first argument. The helper can listen to events on the View module and bolt functionality onto the view. 8 | 9 | Helpers should clear up after themselves. For example if they create variables or bind to events on `setup`, they should be unset and unbound on `teardown`. 10 | 11 | ```js 12 | var myHelper = function(module) { 13 | 14 | // Add functionality 15 | module.on('before setup', function() { /* 1 */ 16 | module.sayName = function() { 17 | return 'My name is ' + module.name; 18 | }; 19 | }); 20 | 21 | // Tidy up 22 | module.on('teardown', function() { 23 | delete module.sayName; 24 | }); 25 | }; 26 | ``` 27 | 28 | 1. *It is often useful to hook into the `before setup` event so that added functionality is available inside the module's `setup` function.* 29 | 30 | ### Attaching helpers 31 | 32 | At definition: 33 | 34 | ```js 35 | var Apple = fruitmachine.define({ 36 | name: 'apple', 37 | helpers: [ myHelper ] 38 | }); 39 | ``` 40 | 41 | ...or instantiation: 42 | 43 | ```js 44 | var apple = new Apple({ 45 | helpers: [ myHelper ] 46 | }); 47 | ``` 48 | 49 | ### Using features 50 | 51 | ```js 52 | apple.sayName(); 53 | //=> 'My name is apple' 54 | ``` 55 | 56 | ### Community Helpers ("Plugins") 57 | 58 | Helpers can be released as plugins, if you would like to submit your helper to this list [please raise an issue](https://github.com/ftlabs/fruitmachine/issues). 59 | 60 | - [fruitmachine-ftdomdelegate](https://github.com/ftlabs/fruitmachine-ftdomdelegate) provides [ftdomdelegate](https://github.com/ftlabs/ftdomdelegate) functionality within fruitmachine modules. 61 | - [fruitmachine-bindall](https://github.com/ftlabs/fruitmachine-bindall) automatically binds all the methods in a module to instances of that module. 62 | - [fruitmachine-media](https://github.com/ftlabs/fruitmachine-media) allows you to create responsive components. Set up media queries for different states and this plugin will allow you to hook into per state setup and teardown events when those media queries match. 63 | -------------------------------------------------------------------------------- /test/tests/module.module.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#module()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | var layout = new Layout({}); 7 | var apple = new Apple({ slot: 1 }); 8 | var orange = new Orange({ slot: 2 }); 9 | var pear = new Pear({ slot: 3 }); 10 | 11 | layout 12 | .add(apple) 13 | .add(orange) 14 | .add(pear); 15 | 16 | viewToTest = layout; 17 | }); 18 | 19 | test("Should return module type if no arguments given", function() { 20 | expect(viewToTest.module()).toBe('layout'); 21 | }); 22 | 23 | test("Should return the first child module with the specified type.", function() { 24 | var child = viewToTest.module('pear'); 25 | 26 | expect(child).toBe(viewToTest.children[2]); 27 | }); 28 | 29 | test("If there is more than one child of this module type, only the first is returned.", function() { 30 | viewToTest 31 | .add({ module: 'apple' }); 32 | 33 | var child = viewToTest.module('apple'); 34 | var firstChild = viewToTest.children[0]; 35 | var lastChild = viewToTest.children[viewToTest.children.length-1]; 36 | 37 | expect(child).toBe(firstChild); 38 | expect(child).not.toEqual(lastChild); 39 | }); 40 | 41 | test("Should return the module name if defined with the name key", function() { 42 | var Henry = fruitmachine.define({ name: 'henry' }); 43 | var henry = new Henry(); 44 | 45 | expect(henry.module()).toBe('henry'); 46 | expect(henry.name).toBe('henry'); 47 | }); 48 | 49 | test("Should walk down the fruitmachine tree, recursively", function() { 50 | var Elizabeth = fruitmachine.define({ name: 'elizabeth' }); 51 | var elizabeth = new Elizabeth(); 52 | viewToTest.module('apple').add(elizabeth); 53 | 54 | var elizabethInstance = viewToTest.module('elizabeth'); 55 | expect(elizabethInstance.module()).toBe('elizabeth'); 56 | expect(elizabethInstance.name).toBe('elizabeth'); 57 | }); 58 | 59 | test("Regression Test: Should still recurse even if the root view used to have a module of the same type", function() { 60 | var pear = viewToTest.module('pear').remove(); 61 | viewToTest.module('apple').add(pear); 62 | 63 | var pearInstance = viewToTest.module('pear'); 64 | expect(pearInstance.module()).toBe('pear'); 65 | expect(pearInstance.name).toBe('pear'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/tests/module.setup.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#setup()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Setup should recurse.", function() { 10 | var setup = jest.spyOn(viewToTest.module('orange'), 'setup'); 11 | 12 | viewToTest 13 | .render() 14 | .setup(); 15 | 16 | expect(setup).toHaveBeenCalled(); 17 | }); 18 | 19 | test("Should not recurse if used with the `shallow` option.", function() { 20 | var setup = jest.spyOn(viewToTest.module('orange'), 'setup'); 21 | 22 | viewToTest 23 | .render() 24 | .setup({ shallow: true }); 25 | 26 | expect(setup).not.toHaveBeenCalled(); 27 | }); 28 | 29 | test("Custom `setup` logic should be called", function() { 30 | var setup = jest.spyOn(helpers.Views.Apple.prototype, 'setup'); 31 | var apple = new helpers.Views.Apple(); 32 | 33 | apple 34 | .render() 35 | .setup(); 36 | 37 | expect(setup).toHaveBeenCalled(); 38 | setup.mockReset(); 39 | }); 40 | 41 | test("Once setup, a View should be flagged as such.", function() { 42 | viewToTest 43 | .render() 44 | .setup(); 45 | 46 | expect(viewToTest.isSetup).toBe(true); 47 | expect(viewToTest.module('orange').isSetup).toBe(true); 48 | }); 49 | 50 | test("Custom `setup` logic should not be run if no root element is found.", function() { 51 | var setup = jest.spyOn(viewToTest, '_setup'); 52 | var setup2 = jest.spyOn(viewToTest.module('orange'), '_setup'); 53 | 54 | viewToTest 55 | .setup(); 56 | 57 | // Check `onSetup` was not called 58 | expect(setup).not.toHaveBeenCalled(); 59 | expect(setup2).not.toHaveBeenCalled(); 60 | 61 | // Check the view hasn't been flagged as setup 62 | expect(viewToTest.isSetup).not.toBe(true); 63 | expect(viewToTest.module('orange').isSetup).not.toBe(true); 64 | }); 65 | 66 | test("onTeardown should be called if `setup()` is called twice.", function() { 67 | var teardown = jest.spyOn(viewToTest, 'teardown'); 68 | var teardown2 = jest.spyOn(viewToTest.module('orange'), 'teardown'); 69 | 70 | //debugger; 71 | viewToTest 72 | .render() 73 | .inject(sandbox) 74 | .setup() 75 | .setup(); 76 | 77 | expect(teardown).toHaveBeenCalled(); 78 | expect(teardown2).toHaveBeenCalled(); 79 | }); 80 | 81 | afterEach(function() { 82 | helpers.destroyView(); 83 | viewToTest = null; 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | FruitMachine is used to assemble nested views from defined modules. It can be used solely on the client, server (via Node), or both. Unlike other solutions, FruitMachine doesn't try to architect your application for you, it simply provides you with the tools to assemble and communicate with your view modules. 4 | 5 | #### What is a 'module'? 6 | 7 | When referring to a module we mean a reusable UI component. For example let's use the common 'tabbed container' component as an example module. 8 | 9 | Our tabbed container needs some markup, some styling and some basic JavaScript interactions. We might want to use this module in two different places within our app, but we don't want to have to write the markup, the styling or the interaction logic twice. When writing modular components we only have to write things once! 10 | 11 | #### What is a 'layout'? 12 | 13 | As far as FruitMachine is concerned there is no difference between layouts and modules, all modules are the same; they are a piece of the UI that has a template, maybe some interaction logic, and perhaps holds some child modules. 14 | 15 | When we talk about layout modules we are referring to the core page scaffolding; a module that usually fills the page, and defines gaps for other modules to sit in. 16 | 17 | #### Comparisons with the DOM 18 | 19 | A collection of FruitMachine modules is like a simplified DOM tree. Like elements, modules have properties, methods and can hold children. There is no limit to how deeply nested modules can be. When an event is fired on a module (`apple.fire('somethinghappened');`, it will bubble right to top of the structure, just like DOM events. 20 | 21 | #### What about my data/models? 22 | 23 | FruitMachine tries to stay as far away from your data as possible, but of course each module must have data associated with it, and FruitMachine must be able to drop this data into the module's template. 24 | 25 | FruitMachine comes with it's own Model class (`fruitmachine.Model`) out of the box, just in case you don't have you own; but we have built FruitMachine such that you can use your own types of Model should you wish. FruitMachine just requires you model to have a .`toJSON()` method so that it send its data into the module's template. 26 | 27 | #### What templating language does it use? 28 | 29 | FruitMachine doesn't care what type of templates you are using, it just expects to be given a function that will return a string. FruitMachine will pass any model data associated with the model as the first argument to this function. This means you can use any templates you like! We like to use [Hogan](http://twitter.github.io/hogan.js/). 30 | -------------------------------------------------------------------------------- /test/tests/module.add.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#add()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = new helpers.Views.List(); 7 | }); 8 | 9 | test("Should throw when adding undefined module", function() { 10 | var thrown; 11 | try { 12 | viewToTest.add({module: 'invalidFruit'}); 13 | } catch(e) { 14 | expect(e.message).toMatch('invalidFruit'); 15 | thrown = true; 16 | } 17 | expect(thrown).toBe(true); 18 | }); 19 | 20 | test("Should accept a View instance", function() { 21 | var pear = new helpers.Views.Pear(); 22 | viewToTest.add(pear); 23 | expect(viewToTest.children.length).toBe(1); 24 | }); 25 | 26 | test("Should store a reference to the child via slot if the view added has a slot", function() { 27 | var apple = new Apple({ slot: 1 }); 28 | var layout = new Layout(); 29 | 30 | layout.add(apple); 31 | 32 | expect(layout.slots[1]).toBe(apple); 33 | }); 34 | 35 | test("Should aceept JSON", function() { 36 | viewToTest.add({ module: 'pear' }); 37 | expect(viewToTest.children.length).toBe(1); 38 | }); 39 | 40 | test("Should allow the second parameter to define the slot", function() { 41 | var apple = new Apple(); 42 | var layout = new Layout(); 43 | 44 | layout.add(apple, 1); 45 | expect(layout.slots[1]).toBe(apple); 46 | }); 47 | 48 | test("Should be able to define the slot in the options object", function() { 49 | var apple = new Apple(); 50 | var layout = new Layout(); 51 | 52 | layout.add(apple, { slot: 1 }); 53 | expect(layout.slots[1]).toBe(apple); 54 | }); 55 | 56 | test("Should remove a module if it already occupies this slot", function() { 57 | var apple = new Apple(); 58 | var orange = new Orange(); 59 | var layout = new Layout(); 60 | 61 | layout.add(apple, 1); 62 | 63 | expect(layout.slots[1]).toBe(apple); 64 | 65 | layout.add(orange, 1); 66 | 67 | expect(layout.slots[1]).toBe(orange); 68 | expect(layout.module('apple')).toBeUndefined(); 69 | }); 70 | 71 | test("Should remove the module if it already has parent before being added", function() { 72 | var apple = new Apple(); 73 | var layout = new Layout(); 74 | var spy = jest.spyOn(apple, 'remove'); 75 | 76 | layout.add(apple, 1); 77 | 78 | expect(spy).toHaveBeenCalledTimes(0); 79 | expect(layout.slots[1]).toBe(apple); 80 | 81 | layout.add(apple, 2); 82 | 83 | expect(layout.slots[1]).not.toEqual(apple); 84 | expect(layout.slots[2]).toBe(apple); 85 | expect(spy).toHaveBeenCalled(); 86 | }); 87 | 88 | afterEach(function() { 89 | helpers.destroyView(viewToTest); 90 | viewToTest = null; 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | ## Getting started 2 | 3 | Let's start with a very simple example to demonstrate how to work with FruitMachine ([working example here](http://ftlabs.github.io/fruitmachine/examples/getting-started)): 4 | 5 | #### Define some modules 6 | 7 | ```js 8 | var Apple = fruitmachine.define({ 9 | name: 'apple', 10 | template: function(){ return 'I am an apple'; } /* 1 */ 11 | }); 12 | 13 | var Layout = fruitmachine.define({ 14 | name: 'layout', 15 | template: function(data){ return data.child1; } /* 1 */ 16 | }); 17 | ``` 18 | 19 | 1. *For simplicity we are using plain functions for templating. These can be switched for more advanced templating functions (eg. Hogan/Mustache).* 20 | 21 | #### Assemble a view 22 | 23 | ```js 24 | var layout = new Layout(); 25 | var apple = new Apple({ id: 'child1' }); /* 1 */ 26 | 27 | layout.add(apple); /* 2 */ 28 | ``` 29 | 30 | 1. *Notice how we give this instance of apple an id. This is the module's identifier within this view, it should be unique. We use this id to print the child into the parent's template.* 31 | 2. *Here we add the apple view module as a child to the layout view module.* 32 | 33 | #### Rendering the view 34 | 35 | ```js 36 | layout.render(); /* 1 */ 37 | ``` 38 | 39 | 1. *At this point `layout.el` will be populated with a real DOM node* 40 | 41 | #### Injecting the view into the DOM 42 | 43 | ```js 44 | layout.inject(document.body); /* 1 */ 45 | document.body.innerHTML; //=>
    I am an apple
    /* 2 */ 46 | ``` 47 | 48 | 1. *The contents of the element passed are replaced with the view module's element* 49 | 2. *You can see the `innerHTML` of the `` is now the generated markup of our view. Don't worry about the id attributes, they are generated by FruitMachine, and are used internally to retrieve views from the DOM.* 50 | 51 | ### Lazy Views 52 | 53 | FruitMachine was written for flexibility so there is usually more than one way to do something. In this case the above code could also be written like this: 54 | 55 | ```js 56 | var Apple = fruitmachine.define({ 57 | name: 'apple', 58 | template: function(){ return 'I am an apple' } /* 1 */ 59 | }); 60 | 61 | var Layout = fruitmachine.define({ 62 | name: 'layout', 63 | template: function(data){ return data.child1 } /* 1 */ 64 | }); 65 | ``` 66 | 67 | 68 | ```js 69 | var layout = fruitmachine({ 70 | module: 'layout', /* 1 */ 71 | children: [ 72 | { 73 | name: 'apple', /* 1 */ 74 | id: 'child1' 75 | } 76 | ] 77 | }); 78 | 79 | layout 80 | .render() 81 | .inject(document.body); 82 | ``` 83 | 84 | 1. *Because we have defined modules under these names, FruitMachine is able to instantiate them internally.* 85 | 86 | It is useful to be able to assemble views in this way as it means that you can predefine your layouts as simple JSON, letting FruitMachine do the hard work. For some parts of your application this may not be preferable. In the FT Web App we use a combination of the two techniques. 87 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | var Hogan = require('hogan.js'); 2 | var fruitmachine = require('../lib/'); 3 | 4 | var helpers = {}; 5 | 6 | /** 7 | * Templates 8 | */ 9 | 10 | var templates = helpers.templates = { 11 | 'apple': Hogan.compile('{{{1}}}'), 12 | 'layout': Hogan.compile('{{{1}}}{{{2}}}{{{3}}}'), 13 | 'list': Hogan.compile('{{#children}}{{{child}}}{{/children}}'), 14 | 'orange': Hogan.compile('{{text}}'), 15 | 'pear': Hogan.compile('{{text}}') 16 | }; 17 | 18 | /** 19 | * Module Definitions 20 | */ 21 | 22 | helpers.Views = {}; 23 | 24 | var Layout = helpers.Views.Layout = fruitmachine.define({ 25 | name: 'layout', 26 | template: templates.layout, 27 | 28 | initialize: function() {}, 29 | setup: function() {}, 30 | teardown: function() {}, 31 | destroy: function() {} 32 | }); 33 | 34 | var Apple = helpers.Views.Apple = fruitmachine.define({ 35 | name: 'apple', 36 | template: templates.apple, 37 | 38 | initialize: function() {}, 39 | setup: function() {}, 40 | teardown: function() {}, 41 | destroy: function() {} 42 | }); 43 | 44 | var List = helpers.Views.List = fruitmachine.define({ 45 | name: 'list', 46 | template: templates.list, 47 | 48 | initialize: function() {}, 49 | setup: function() {}, 50 | teardown: function() {}, 51 | destroy: function() {} 52 | }); 53 | 54 | var Orange = helpers.Views.Orange = fruitmachine.define({ 55 | name: 'orange', 56 | template: templates.orange, 57 | 58 | initialize: function() {}, 59 | setup: function() {}, 60 | teardown: function() {}, 61 | destroy: function() {} 62 | }); 63 | 64 | var Pear = helpers.Views.Pear = fruitmachine.define({ 65 | name: 'pear', 66 | template: templates.pear, 67 | 68 | initialize: function() {}, 69 | setup: function() {}, 70 | teardown: function() {}, 71 | destroy: function() {} 72 | }); 73 | 74 | /** 75 | * Create View 76 | */ 77 | 78 | helpers.createView = function() { 79 | var layout = new Layout(); 80 | var apple = new Apple({ slot: 1 }); 81 | var orange = new Orange({ slot: 2 }); 82 | var pear = new Pear({ slot: 3 }); 83 | 84 | layout 85 | .add(apple) 86 | .add(orange) 87 | .add(pear); 88 | 89 | return this.view = layout; 90 | }; 91 | 92 | /** 93 | * Destroy View 94 | */ 95 | 96 | helpers.destroyView = function(view) { 97 | var viewToDestroy = this.view || view; 98 | viewToDestroy.destroy(); 99 | this.view = null; 100 | }; 101 | 102 | /** 103 | * Sandbox 104 | */ 105 | 106 | helpers.createSandbox = function() { 107 | var el = document.createElement('div'); 108 | return document.body.appendChild(el); 109 | }; 110 | 111 | helpers.emptySandbox = function() { 112 | sandbox.innerHTML = ''; 113 | }; 114 | 115 | var sandbox = helpers.createSandbox(); 116 | 117 | global.fruitmachine = fruitmachine; 118 | global.helpers = helpers; 119 | global.sandbox = sandbox; 120 | global.Layout = Layout; 121 | global.Apple = Apple; 122 | global.List = List; 123 | global.Orange = Orange; 124 | global.Pear = Pear; 125 | -------------------------------------------------------------------------------- /test/tests/module.destroy.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#destroy()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should recurse.", function() { 10 | var destroy = jest.spyOn(viewToTest, 'destroy'); 11 | var destroy2 = jest.spyOn(viewToTest.module('orange'), 'destroy'); 12 | 13 | viewToTest 14 | .render() 15 | .inject(sandbox) 16 | .setup(); 17 | 18 | viewToTest.destroy(); 19 | 20 | expect(destroy).toHaveBeenCalled(); 21 | expect(destroy2).toHaveBeenCalled(); 22 | }); 23 | 24 | test("Should call teardown once per view.", function() { 25 | var teardown1 = jest.spyOn(viewToTest, 'teardown'); 26 | var teardown2 = jest.spyOn(viewToTest.module('orange'), 'teardown'); 27 | 28 | viewToTest 29 | .render() 30 | .inject(sandbox) 31 | .setup() 32 | .destroy(); 33 | 34 | expect(teardown1).toHaveBeenCalledTimes(1) 35 | expect(teardown2).toHaveBeenCalledTimes(1) 36 | }); 37 | 38 | test("Should remove only the first view element from the DOM.", function() { 39 | var layout = viewToTest; 40 | var orange = viewToTest.module('orange'); 41 | 42 | layout 43 | .render() 44 | .inject(sandbox) 45 | .setup(); 46 | 47 | var layoutRemoveChild = jest.spyOn(layout.el.parentNode, 'removeChild'); 48 | var orangeRemoveChild = jest.spyOn(orange.el.parentNode, 'removeChild'); 49 | 50 | viewToTest.destroy(); 51 | 52 | expect(layoutRemoveChild).toHaveBeenCalledTimes(1) 53 | expect(orangeRemoveChild).toHaveBeenCalledTimes(0) 54 | 55 | layoutRemoveChild.mockRestore(); 56 | orangeRemoveChild.mockRestore(); 57 | }); 58 | 59 | test("Should fire `destroy` event.", function() { 60 | var spy = jest.fn(); 61 | 62 | viewToTest.on('destroy', spy); 63 | 64 | viewToTest 65 | .render() 66 | .inject(sandbox) 67 | .setup() 68 | .destroy(); 69 | 70 | expect(spy).toHaveBeenCalled(); 71 | }); 72 | 73 | test("Should unbind all event listeners.", function() { 74 | var eventSpy = jest.spyOn(viewToTest, 'off'); 75 | 76 | viewToTest 77 | .render() 78 | .inject(sandbox) 79 | .setup() 80 | .destroy(); 81 | 82 | expect(eventSpy).toHaveBeenCalled(); 83 | }); 84 | 85 | test("Should flag the view as 'destroyed'.", function() { 86 | viewToTest 87 | .render() 88 | .inject(sandbox) 89 | .setup() 90 | .destroy(); 91 | 92 | expect(viewToTest.destroyed).toBe(true); 93 | }); 94 | 95 | test("Should unset primary properties.", function() { 96 | viewToTest 97 | .render() 98 | .inject(sandbox) 99 | .setup() 100 | .destroy(); 101 | 102 | expect(viewToTest.el).toBeNull() 103 | expect(viewToTest.model).toBeNull() 104 | expect(viewToTest.parent).toBeNull() 105 | expect(viewToTest._id).toBeNull() 106 | }); 107 | 108 | afterEach(function() { 109 | helpers.destroyView(); 110 | viewToTest = null; 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FruitMachine [![Build Status](https://api.travis-ci.com/ftlabs/fruitmachine.svg)](https://travis-ci.com/ftlabs/fruitmachine) [![Coverage Status](https://coveralls.io/repos/ftlabs/fruitmachine/badge.png)](https://coveralls.io/r/ftlabs/fruitmachine) 2 | 3 | A lightweight component layout engine for client and server. 4 | 5 | FruitMachine is designed to build rich interactive layouts from modular, reusable components. It's light and unopinionated so that it can be applied to almost any layout problem. FruitMachine is currently powering the [FT Web App](http://apps.ft.com/ftwebapp/). 6 | 7 | ```js 8 | // Define a module 9 | var Apple = fruitmachine.define({ 10 | name: 'apple', 11 | template: function(){ return 'hello' } 12 | }); 13 | 14 | // Create a module 15 | var apple = new Apple(); 16 | 17 | // Render it 18 | apple.render(); 19 | 20 | apple.el.outerHTML; 21 | //=>
    hello
    22 | ``` 23 | 24 | ## Installation 25 | 26 | ``` 27 | $ npm install fruitmachine 28 | ``` 29 | 30 | or 31 | 32 | ``` 33 | $ bower install fruitmachine 34 | ``` 35 | 36 | or 37 | 38 | Download the [pre-built version][built] (~2k gzipped). 39 | 40 | [built]: http://wzrd.in/standalone/fruitmachine@latest 41 | 42 | ## Examples 43 | 44 | - [Article viewer](http://ftlabs.github.io/fruitmachine/examples/article-viewer/) 45 | - [TODO](http://ftlabs.github.io/fruitmachine/examples/todo/) 46 | 47 | ## Documentation 48 | 49 | - [Introduction](docs/introduction.md) 50 | - [Getting started](docs/getting-started.md) 51 | - [Defining modules](docs/defining-modules.md) 52 | - [Slots](docs/slots.md) 53 | - [View assembly](docs/layout-assembly.md) 54 | - [Instantiation](docs/module-instantiation.md) 55 | - [Templates](docs/templates.md) 56 | - [Template markup](docs/template-markup.md) 57 | - [Rendering](docs/rendering.md) 58 | - [DOM injection](docs/injection.md) 59 | - [The module element](docs/module-el.md) 60 | - [Queries](docs/queries.md) 61 | - [Helpers](docs/module-helpers.md) 62 | - [Removing & destroying](docs/removing-and-destroying.md) 63 | - [Extending](docs/extending-modules.md) 64 | - [Server-side rendering](docs/server-side-rendering.md) 65 | - [API](docs/api.md) 66 | - [Events](docs/events.md) 67 | 68 | ## Tests 69 | 70 | #### With PhantomJS 71 | 72 | ``` 73 | $ npm install 74 | $ npm test 75 | ``` 76 | 77 | #### Without PhantomJS 78 | 79 | ``` 80 | $ node_modules/.bin/buster-static 81 | ``` 82 | 83 | ...then visit http://localhost:8282/ in browser 84 | 85 | ## Author 86 | 87 | - **Wilson Page** - [@wilsonpage](http://github.com/wilsonpage) 88 | 89 | ## Contributors 90 | 91 | - **Wilson Page** - [@wilsonpage](http://github.com/wilsonpage) 92 | - **Matt Andrews** - [@matthew-andrews](http://github.com/matthew-andrews) 93 | 94 | ## License 95 | Copyright (c) 2018 The Financial Times Limited 96 | Licensed under the MIT license. 97 | 98 | ## Credits and collaboration 99 | FruitMachine is largely unmaintained/finished. All open source code released by FT Labs is licenced under the MIT licence. We welcome comments, feedback and suggestions. Please feel free to raise an issue or pull request. 100 | -------------------------------------------------------------------------------- /test/tests/module.mount.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#mount()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should give a view an element", function() { 10 | var el = document.createElement('div'); 11 | viewToTest.mount(el); 12 | 13 | expect(viewToTest.el).toBe(el); 14 | }); 15 | 16 | test("Should be called when the view is rendered", function() { 17 | var mount = jest.spyOn(viewToTest, 'mount'); 18 | viewToTest.render(); 19 | expect(mount).toHaveBeenCalled(); 20 | }); 21 | 22 | test("Should be called on a child when its parent is rendered", function() { 23 | var mount = jest.spyOn(viewToTest.module('apple'), 'mount'); 24 | viewToTest.render(); 25 | expect(mount).toHaveBeenCalled(); 26 | }); 27 | 28 | test("Should be called on a child when its parent is rerendered", function() { 29 | var mount = jest.spyOn(viewToTest.module('apple'), 'mount'); 30 | viewToTest.render(); 31 | viewToTest.render(); 32 | expect(mount).toHaveBeenCalledTimes(2); 33 | }); 34 | 35 | test("Should call custom mount logic", function() { 36 | var mount = jest.fn(); 37 | 38 | var Module = fruitmachine.define({ 39 | name: 'module', 40 | template: function() { 41 | return 'hello'; 42 | }, 43 | 44 | mount: mount 45 | }); 46 | 47 | var m = new Module(); 48 | m.render(); 49 | 50 | expect(mount).toHaveBeenCalled(); 51 | }); 52 | 53 | test("Should be a good place to attach event handlers that don't get trashed on parent rerender", function() { 54 | var handler = jest.fn(); 55 | 56 | var Module = fruitmachine.define({ 57 | name: 'module', 58 | tag: 'button', 59 | template: function() { 60 | return 'hello'; 61 | }, 62 | 63 | mount: function() { 64 | this.el.addEventListener('click', handler); 65 | } 66 | }); 67 | 68 | var m = new Module(); 69 | 70 | var layout = new Layout({ 71 | children: { 72 | 1: m 73 | } 74 | }); 75 | 76 | layout.render(); 77 | m.el.click(); 78 | 79 | expect(handler).toHaveBeenCalledTimes(1); 80 | 81 | layout.render(); 82 | m.el.click(); 83 | 84 | expect(handler).toHaveBeenCalledTimes(2); 85 | }); 86 | 87 | test("before mount and mount events should be fired", function() { 88 | var beforeMountSpy = jest.fn(); 89 | var mountSpy = jest.fn(); 90 | viewToTest.on('before mount', beforeMountSpy); 91 | viewToTest.on('mount', mountSpy); 92 | 93 | viewToTest.render(); 94 | expect(beforeMountSpy.mock.invocationCallOrder[0]).toBeLessThan(mountSpy.mock.invocationCallOrder[0]); 95 | }); 96 | 97 | test("Should only fire events if the element is new", function() { 98 | var mountSpy = jest.fn(); 99 | viewToTest.on('mount', mountSpy); 100 | 101 | viewToTest.render(); 102 | viewToTest._getEl(); 103 | expect(mountSpy).toHaveBeenCalledTimes(1) 104 | }); 105 | 106 | afterEach(function() { 107 | helpers.destroyView(); 108 | viewToTest = null; 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /lib/module/events.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module Dependencies 4 | */ 5 | 6 | var events = require('evt'); 7 | 8 | /** 9 | * Local vars 10 | */ 11 | 12 | var listenerMap = {}; 13 | 14 | /** 15 | * Registers a event listener. 16 | * 17 | * @param {String} name 18 | * @param {String} module 19 | * @param {Function} cb 20 | * @return {View} 21 | */ 22 | exports.on = function(name, module, cb) { 23 | var l; 24 | 25 | // cb can be passed as 26 | // the second or third argument 27 | if (typeof module !== 'string') { 28 | cb = module; 29 | module = null; 30 | } 31 | 32 | // if a module is provided 33 | // pass in a special callback 34 | // function that checks the 35 | // module 36 | if (module) { 37 | if (!listenerMap[name]) listenerMap[name] = []; 38 | l = listenerMap[name].push({ 39 | orig: cb, 40 | cb: function() { 41 | if (this.event.target.module() === module) { 42 | cb.apply(this, arguments); 43 | } 44 | } 45 | }); 46 | events.prototype.on.call(this, name, listenerMap[name][l-1].cb); 47 | } else { 48 | events.prototype.on.call(this, name, cb); 49 | } 50 | 51 | return this; 52 | }; 53 | 54 | /** 55 | * Unregisters a event listener. 56 | * 57 | * @param {String} name 58 | * @param {String} module 59 | * @param {Function} cb 60 | * @return {View} 61 | */ 62 | exports.off = function(name, module, cb) { 63 | 64 | // cb can be passed as 65 | // the second or third argument 66 | if (typeof module !== 'string') { 67 | cb = module; 68 | module = null; 69 | } 70 | 71 | if (listenerMap[name]) { 72 | listenerMap[name] = listenerMap[name].filter(function(map) { 73 | 74 | // If a callback provided, keep it 75 | // in the listener map if it doesn't match 76 | if (cb && map.orig !== cb) { 77 | return true; 78 | 79 | // Otherwise remove it from the listener 80 | // map and unbind the event listener 81 | } else { 82 | events.prototype.off.call(this, name, map.cb); 83 | return false; 84 | } 85 | }, this); 86 | } 87 | if (!module) { 88 | events.prototype.off.call(this, name, cb); 89 | } 90 | 91 | return this; 92 | }; 93 | 94 | /** 95 | * Fires an event on a view. 96 | * 97 | * @param {String} name 98 | * @return {View} 99 | */ 100 | exports.fire = function(name) { 101 | var _event = this.event; 102 | var event = { 103 | target: this, 104 | propagate: true, 105 | stopPropagation: function(){ this.propagate = false; } 106 | }; 107 | 108 | propagate(this, arguments, event); 109 | 110 | // COMPLEX: 111 | // If an earlier event object was 112 | // cached, restore the the event 113 | // back onto the view. If there 114 | // wasn't an earlier event, make 115 | // sure the `event` key has been 116 | // deleted off the view. 117 | if (_event) this.event = _event; 118 | else delete this.event; 119 | 120 | // Allow chaining 121 | return this; 122 | }; 123 | 124 | function propagate(view, args, event) { 125 | if (!view || !event.propagate) return; 126 | 127 | view.event = event; 128 | events.prototype.fire.apply(view, args); 129 | propagate(view.parent, args, event); 130 | } 131 | 132 | exports.fireStatic = events.prototype.fire; 133 | -------------------------------------------------------------------------------- /docs/templates/readme.hogan: -------------------------------------------------------------------------------- 1 | # {{pkg.title}} [![Build Status](https://travis-ci.org/ftlabs/fruitmachine.svg?branch=master)](https://travis-ci.org/ftlabs/fruitmachine) [![Coverage Status](https://coveralls.io/repos/ftlabs/fruitmachine/badge.png?branch=master)](https://coveralls.io/r/ftlabs/fruitmachine?branch=master) [![Dependency Status](https://gemnasium.com/ftlabs/fruitmachine.png)](https://gemnasium.com/ftlabs/fruitmachine) 2 | 3 | {{pkg.description}} 4 | 5 | FruitMachine is designed to build rich interactive layouts from modular, reusable components. It's light and unopinionated so that it can be applied to almost any layout problem. FruitMachine is currently powering the [FT Web App](http://apps.ft.com/ftwebapp/). 6 | 7 | ```js 8 | // Define a module 9 | var Apple = fruitmachine.define({ 10 | name: 'apple', 11 | template: function(){ return 'hello' } 12 | }); 13 | 14 | // Create a module 15 | var apple = new Apple(); 16 | 17 | // Render it 18 | apple.render(); 19 | 20 | apple.el.outerHTML; 21 | //=>
    hello
    22 | ``` 23 | 24 | ## Installation 25 | 26 | ``` 27 | $ npm install fruitmachine 28 | ``` 29 | 30 | or 31 | 32 | ``` 33 | $ bower install fruitmachine 34 | ``` 35 | 36 | or 37 | 38 | Download the [pre-built version][built] (~2k gzipped). 39 | 40 | [built]: http://wzrd.in/standalone/fruitmachine@latest 41 | 42 | ## Examples 43 | 44 | - [Article viewer](http://ftlabs.github.io/fruitmachine/examples/article-viewer/) 45 | - [TODO](http://ftlabs.github.io/fruitmachine/examples/todo/) 46 | 47 | ## Documentation 48 | 49 | - [Introduction](docs/introduction.md) 50 | - [Getting started](docs/getting-started.md) 51 | - [Defining modules](docs/defining-modules.md) 52 | - [Slots](docs/slots.md) 53 | - [View assembly](docs/layout-assembly.md) 54 | - [Instantiation](docs/module-instantiation.md) 55 | - [Templates](docs/templates.md) 56 | - [Template markup](docs/template-markup.md) 57 | - [Rendering](docs/rendering.md) 58 | - [DOM injection](docs/injection.md) 59 | - [The module element](docs/module-el.md) 60 | - [Queries](docs/queries.md) 61 | - [Helpers](docs/module-helpers.md) 62 | - [Removing & destroying](docs/removing-and-destroying.md) 63 | - [Extending](docs/extending-modules.md) 64 | - [Server-side rendering](docs/server-side-rendering.md) 65 | - [API](docs/api.md) 66 | - [Events](docs/events.md) 67 | 68 | ## Tests 69 | 70 | #### With PhantomJS 71 | 72 | ``` 73 | $ npm install 74 | $ npm test 75 | ``` 76 | 77 | #### Without PhantomJS 78 | 79 | ``` 80 | $ node_modules/.bin/buster-static 81 | ``` 82 | 83 | ...then visit http://localhost:8282/ in browser 84 | 85 | ## Author 86 | 87 | {{#pkg.author}} 88 | - **{{name}}** - [@{{github}}](http://github.com/{{github}}) 89 | {{/pkg.author}} 90 | 91 | ## Contributors 92 | 93 | {{#pkg.contributors}} 94 | - **{{name}}** - [@{{github}}](http://github.com/{{github}}) 95 | {{/pkg.contributors}} 96 | 97 | ## License 98 | Copyright (c) 2014 {{pkg.organization}} 99 | Licensed under the MIT license. 100 | 101 | ## Credits and collaboration 102 | 103 | The lead developer of {{pkg.title}} is [Wilson Page](http://github.com/wilsonpage) at FT Labs. All open source code released by FT Labs is licenced under the MIT licence. We welcome comments, feedback and suggestions. Please feel free to raise an issue or pull request. 104 | -------------------------------------------------------------------------------- /test/tests/module.on.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#on()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("Should recieve the callback when fire is called directly on a view", function() { 10 | var spy = jest.fn(); 11 | 12 | viewToTest.on('testevent', spy); 13 | viewToTest.fire('testevent'); 14 | expect(spy).toHaveBeenCalled(); 15 | }); 16 | 17 | test("Should recieve the callback when event is fired on a sub view", function() { 18 | var spy = jest.fn(); 19 | var apple = viewToTest.module('apple'); 20 | 21 | viewToTest.on('testevent', spy); 22 | apple.fire('testevent'); 23 | expect(spy).toHaveBeenCalled(); 24 | }); 25 | 26 | test("Should *not* recieve the callback when event is fired on a sub view that *doesn't* match the target", function() { 27 | var spy = jest.fn(); 28 | var apple = viewToTest.module('apple'); 29 | 30 | viewToTest.on('testevent', 'orange', spy); 31 | apple.fire('testevent'); 32 | expect(spy).not.toHaveBeenCalled(); 33 | }); 34 | 35 | test("Should receive the callback when event is fired on a sub view that *does* match the target", function() { 36 | var spy = jest.fn(); 37 | var apple = viewToTest.module('apple'); 38 | 39 | viewToTest.on('testevent', 'apple', spy); 40 | apple.fire('testevent'); 41 | expect(spy).toHaveBeenCalled(); 42 | }); 43 | 44 | test("Should pass the correct arguments to delegate event listeners", function() { 45 | var spy = jest.fn(); 46 | var apple = viewToTest.module('apple'); 47 | 48 | viewToTest.on('testevent', 'apple', spy); 49 | apple.fire('testevent', 'foo', 'bar'); 50 | expect(spy).toHaveBeenCalledWith('foo', 'bar'); 51 | }); 52 | 53 | test("Should be able to unbind event listeners if initially bound with module name", function() { 54 | var spy = jest.fn(); 55 | var apple = viewToTest.module('apple'); 56 | 57 | viewToTest.on('testevent', 'apple', spy); 58 | apple.fire('testevent'); 59 | expect(spy).toHaveBeenCalled(); 60 | 61 | spy.mockClear(); 62 | viewToTest.off('testevent', 'apple', spy); 63 | apple.fire('testevent', 'foo', 'bar'); 64 | expect(spy).not.toHaveBeenCalled(); 65 | }); 66 | 67 | test("#off with module will unbind all matching listeners, regardless of how they are bound", function() { 68 | var spy = jest.fn(); 69 | var spy2 = jest.fn(); 70 | var apple = viewToTest.module('apple'); 71 | 72 | viewToTest.on('testevent', 'apple', spy); 73 | viewToTest.on('testevent', spy2); 74 | apple.fire('testevent'); 75 | expect(spy).toHaveBeenCalled(); 76 | expect(spy2).toHaveBeenCalled(); 77 | 78 | spy.mockClear(); 79 | spy2.mockClear(); 80 | viewToTest.off('testevent', 'apple'); 81 | apple.fire('testevent'); 82 | expect(spy).not.toHaveBeenCalled(); 83 | expect(spy2).toHaveBeenCalled(); 84 | }); 85 | 86 | test("#off without a module should also unbind listeners, regardless of how they are bound", function() { 87 | var spy = jest.fn(); 88 | var apple = viewToTest.module('apple'); 89 | 90 | viewToTest.on('testevent', 'apple', spy); 91 | apple.fire('testevent'); 92 | expect(spy).toHaveBeenCalled(); 93 | 94 | spy.mockClear(); 95 | viewToTest.off('testevent', spy); 96 | apple.fire('testevent'); 97 | expect(spy).not.toHaveBeenCalled(); 98 | }); 99 | 100 | afterEach(function() { 101 | helpers.destroyView(); 102 | viewToTest = null; 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /docs/template-markup.md: -------------------------------------------------------------------------------- 1 | ## Markup 2 | 3 | When FruitMachine renders your modules by calling the template function, it passes some important data, in addition to any that you have explicitly declared yourself. This data includes the rendered markup from each of the module's child modules. It's up to your template function to put it in the right place. 4 | 5 | It gives you the following data: 6 | 7 | - An array of child modules in the form of `children`. 8 | - A variable for each child View in the form of the child's `slot`. 9 | 10 | This gives you the ability to print child HTML exactly where you want it, or to loop and print all children of the current View. If you don't print a module's child module's into the markup then they will not appear in the final HTML markup. 11 | 12 | ### Place child modules by `slot` 13 | 14 | The following example demonstrates how you can place child modules by `slot`. 15 | 16 | ##### Template markup 17 | 18 | *layout.mustache* 19 | 20 | ```html 21 | {{{1}}} 22 | ``` 23 | 24 | In `Layout`'s template we print slots by name. In this case we have named our slot '1' so we have printed `{{{1}}}` into our template. 25 | 26 | *apple.mustache* 27 | 28 | ```html 29 | I am Apple 30 | ``` 31 | 32 | **Remember:** FruitMachine creates the module's root element for you, so your templates need only contain the markup for the module's contents. 33 | 34 | ##### Define modules 35 | 36 | ```js 37 | var Layout = fruitmachine.define({ 38 | name: 'layout', 39 | template: layoutTemplate 40 | }); 41 | 42 | var Apple = fruitmachine.define({ 43 | name: 'apple', 44 | template: appleTemplate 45 | }); 46 | ``` 47 | 48 | ##### Create assemble modules 49 | 50 | ```js 51 | var apple = new Apple(); 52 | var layout = new Layout(); 53 | 54 | // Add a child view 55 | layout.add(apple, { slot: 1 }); 56 | ``` 57 | 58 | We created an instance of our `Layout` module, then an instance of our `Apple` module. We then added the `apple` module as a child of the `layout` module. In the options object we defined which slot we wanted the `apple` module to sit in. 59 | 60 | ##### Render 61 | 62 | ```js 63 | layout.render(); 64 | layout.el.outerHTML; 65 | //=>
    66 | //
    I am Apple
    67 | //
    68 | ``` 69 | 70 | ### Loop and place all child modules 71 | 72 | In some cases the number of child modules is not known, and we just want to render them all. The list.mustache template uses the special `children` (Array) and `child` (HTML string) keys to iterate and print each module's HTML. In this example we are using dummy `List` and `Item` module constructors. 73 | 74 | ##### Create the module 75 | 76 | ```js 77 | var list = new List(); 78 | var item1 = new Item({ model: { name: 'Wilson' } }); 79 | var item2 = new Item({ model: { name: 'Matt' } }); 80 | var item3 = new Item({ model: { name: 'Jim' } }); 81 | 82 | list 83 | .add(item1) 84 | .add(item2) 85 | .add(item3); 86 | ``` 87 | 88 | ##### Template markup 89 | 90 | *list.mustache* 91 | 92 | ```html 93 | {{#children}} 94 | {{{child}}} 95 | {{#children}} 96 | ``` 97 | 98 | **Note:** It's worth noting that within the scope of the loop, the current child's `model` is accessible. So `{{name}}` within the `{{#children}}` loop would work. 99 | 100 | *item.mustache* 101 | 102 | ```html 103 | My name is {{name}} 104 | ``` 105 | 106 | ##### Render 107 | 108 | ```js 109 | layout.render(); 110 | layout.el.outerHTML; 111 | //=>
    112 | //
    My name is Wilson
    113 | //
    My name is Matt
    114 | //
    My name is Jim
    115 | //
    116 | ``` 117 | 118 | *NB: id attributes have been omitted from markup examples for clarity* 119 | -------------------------------------------------------------------------------- /examples/lib/database.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Dummy Data Stores 4 | */ 5 | 6 | var database = {}; 7 | 8 | // Mock synchronous API 9 | database.getSync = function() { 10 | return [ 11 | { 12 | id: 'article1', 13 | title: 'Article 1' 14 | }, 15 | { 16 | id: 'article2', 17 | title: 'Article 2' 18 | }, 19 | { 20 | id: 'article3', 21 | title: 'Article 3' 22 | }, 23 | { 24 | id: 'article4', 25 | title: 'Article 4' 26 | }, 27 | { 28 | id: 'article5', 29 | title: 'Article 5' 30 | } 31 | ]; 32 | }; 33 | 34 | // Mock asynchonous API 35 | database.getAsync = function(id, callback) { 36 | var database = { 37 | article1: { 38 | date: '3rd May 2012', 39 | title: 'Article 1', 40 | body: "

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam volutpat sem dictum, bibendum orci sed, auctor nulla. Nullam mauris eros, lobortis quis mi quis, commodo pellentesque dolor. Fusce purus odio, rutrum id malesuada in, volutpat ut augue. Vivamus in neque posuere, porta ipsum sed, lacinia sem. In tortor turpis, rhoncus consequat elit nec, condimentum accumsan ipsum. Vestibulum sed pellentesque urna. Duis rutrum pulvinar accumsan. Integer sagittis ante enim, ac porttitor ligula rutrum quis.

    ", 41 | author: 'John Smith' 42 | }, 43 | article2: { 44 | date: '13th August 2012', 45 | title: 'Article 2', 46 | body: "

    Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Integer vulputate aliquet quam at aliquam. Praesent pellentesque mauris ut augue congue, sit amet mattis sapien ultrices. Phasellus at semper massa. Pellentesque sollicitudin egestas enim ac rhoncus. Vestibulum quis vehicula turpis, hendrerit dapibus nunc. Etiam eget libero efficitur, vehicula risus id, efficitur neque. Maecenas accumsan tincidunt ultrices. Vestibulum sagittis, felis sed commodo pharetra, velit dolor congue velit, nec porta leo leo sit amet neque. Donec imperdiet porttitor neque, eget faucibus odio eleifend ut.

    ", 47 | author: 'John Smith' 48 | }, 49 | article3: { 50 | date: '27th July 2012', 51 | title: 'Article 3', 52 | body: "

    Curabitur eget feugiat leo. Nulla lorem nisl, malesuada vel erat eu, mattis viverra magna. Praesent facilisis ornare tristique. Sed congue accumsan lacus, non consequat augue hendrerit et. Maecenas imperdiet placerat leo, sed auctor neque suscipit eget. Aliquam a porttitor massa. Quisque porttitor sed urna eget auctor.

    ", 53 | author: 'John Smith' 54 | }, 55 | article4: { 56 | date: '6th March 2013', 57 | title: 'Article 4', 58 | body: "

    Vestibulum consectetur, nunc sit amet sodales pharetra, arcu diam molestie ante, ac viverra erat justo id velit. Maecenas consequat fringilla lectus, id pretium ipsum viverra quis. Sed tortor urna, tincidunt ac laoreet eu, finibus finibus sem. Pellentesque venenatis risus sem, eu lacinia neque fermentum eu. In at dui ut odio elementum venenatis at eu tortor. Curabitur vel dui felis. Maecenas sollicitudin, erat sit amet facilisis vehicula, dolor lectus mattis libero, in sagittis justo lorem et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Vestibulum tincidunt ante eget ex gravida, vitae bibendum urna fermentum. Donec commodo magna vel malesuada volutpat. Etiam in ipsum nec est eleifend euismod. Mauris a justo justo. Aenean pulvinar aliquam ligula, at bibendum velit imperdiet at. Etiam euismod tristique ex quis placerat. Morbi mi lorem, cursus in tempus vitae, mollis in risus.

    ", 59 | author: 'John Smith' 60 | }, 61 | article5: { 62 | date: '24th December 2012', 63 | title: 'Article 5', 64 | body: '

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.

    ', 65 | author: 'John Smith' 66 | } 67 | }; 68 | 69 | setTimeout(function() { 70 | callback(database[id]); 71 | }, 100); 72 | }; 73 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | ## Events 2 | 3 | Events are at the core of fruitmachine. They allows us to decouple View interactions from one another. By default *FruitMachine* fires the following events on View module instances during creation, in the following order: 4 | 5 | - `before initialize` Before the module is instantiated 6 | - `initialize` On instantiation 7 | - `before render` At the very start of the render process, triggered by the view having `.render()` called on it 8 | - `before tohtml` Before toHTML is called. `render` events are only fired on the node being rendered - not any of the children so if you want to manipulate a module's data model prior to rendering, hook into this event) 9 | - `render` When the `.render()` process is complete 10 | - `before setup` Before the module is setup 11 | - `setup` When `.setup()` is called to set up events after render (remember 'setup' is recursive) 12 | 13 | And during destruction, in the following order: 14 | - `before teardown` Before the module is torn down 15 | - `teardown` When `.teardown()` or `.destroy()` are called (remember 'destroy' calls 'teardown' which recurses) 16 | - `before destroy` Before the module is destroyed 17 | - `destroy` When `.destroy()` is called (remember 'teardown' recurses) 18 | 19 | #### Bubbling 20 | 21 | FruitMachine events are interesting as they propagate (or bubble) up the view chain. This means that parent Views way up the view chain can still listen to events that happen in deeply nested View modules. 22 | 23 | This is useful because it means your app's controllers can listen and decide what to do when specific things happen within your views. The response logic doesn't have to be in the view module itself, meaning modules are decoupled from your app, and easily reused elsewhere. 24 | 25 | ```js 26 | var layout = new Layout(); 27 | var apple = new Apple(); 28 | 29 | layout.add(apple); 30 | 31 | layout.on('shout', function() { 32 | alert('layout heard apple shout'); 33 | }); 34 | 35 | apple.fire('shout'); 36 | //=> alert 'layout heard apple shout' 37 | ``` 38 | 39 | The FruitMachine default events (eg `initialize`, `setup`, `teardown`) do not bubble. 40 | 41 | #### Passing parameters 42 | 43 | ```js 44 | var layout = new Layout(); 45 | var apple = new Apple(); 46 | 47 | layout.add(apple); 48 | 49 | layout.on('shout', function(param) { 50 | alert('layout heard apple shout ' + param); 51 | }); 52 | 53 | apple.fire('shout', 'hello'); 54 | // alert - 'layout heard apple shout hello' 55 | ``` 56 | 57 | #### Listening only for specific modules 58 | 59 | ```js 60 | var layout = new Layout(); 61 | var apple = new Apple(); 62 | var orange = new Orange(); 63 | 64 | layout 65 | .add(apple) 66 | .add(orange); 67 | 68 | layout.on('shout', 'apple', function() { 69 | alert('layout heard apple shout'); 70 | }); 71 | 72 | apple.fire('shout'); 73 | //=> alert 'layout heard apple shout' 74 | 75 | orange.fire('shout'); 76 | //=> nothing 77 | ``` 78 | 79 | #### Utilising the event object 80 | 81 | The event object can be found on under `this.event`. It holds a reference to the target view, where the event originated. 82 | 83 | ```js 84 | var layout = new Layout(); 85 | var apple = new Apple(); 86 | var orange = new Orange(); 87 | 88 | layout 89 | .add(apple) 90 | .add(orange); 91 | 92 | layout.on('shout', function() { 93 | var module = this.event.target.module(); 94 | alert('layout heard ' + module + ' shout'); 95 | }); 96 | 97 | apple.fire('shout'); 98 | //=> alert 'layout heard apple shout' 99 | 100 | orange.fire('shout'); 101 | //=> alert 'layout heard orange shout' 102 | ``` 103 | 104 | It also allows you to stop the event propagating (bubbling) up the view by calling `this.event.stopPropagation()`, just like DOM events! 105 | 106 | ```js 107 | var layout = new Layout(); 108 | var apple = new Apple(); 109 | var orange = new Orange(); 110 | 111 | layout 112 | .add(apple) 113 | .add(orange); 114 | 115 | layout.on('shout', function() { 116 | alert('layout heard apple shout'); 117 | }); 118 | 119 | apple.on('shout', function() { 120 | this.event.stopPropagation(); /* 1 */ 121 | }); 122 | 123 | apple.fire('shout'); 124 | //=> nothing 125 | ``` 126 | 127 | 1. *By stopping propagation here, we stop the event from ever reaching the parent view `layout`, and thus the alert is never fired.* 128 | 129 | -------------------------------------------------------------------------------- /test/tests/module.remove.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#remove()', function() { 3 | 4 | test("Should remove the child passed from the parent's children array", function() { 5 | var list = new helpers.Views.Layout(); 6 | var Apple = helpers.Views.Apple; 7 | var apple1 = new Apple(); 8 | var apple2 = new Apple(); 9 | 10 | list 11 | .add(apple1) 12 | .add(apple2); 13 | 14 | list.remove(apple1); 15 | 16 | expect(list.children.indexOf(apple1)).toBe(-1); 17 | }); 18 | 19 | test("Should remove all lookup references", function() { 20 | var list = new helpers.Views.Layout(); 21 | var Apple = helpers.Views.Apple; 22 | var apple = new Apple({ id: 'foo' }); 23 | 24 | list.add(apple); 25 | 26 | expect(list._ids.foo).toBeTruthy(); 27 | expect(list._modules.apple[0]).toBe(apple); 28 | 29 | list.remove(apple); 30 | 31 | expect(list._ids.foo).toBeUndefined(); 32 | expect(list._modules.apple[0]).toBeUndefined(); 33 | }); 34 | 35 | test("Should remove the child from the DOM by default", function() { 36 | var sandbox = helpers.createSandbox(); 37 | var list = new helpers.Views.Layout(); 38 | var Apple = helpers.Views.Apple; 39 | var apple = new Apple({ slot: 1 }); 40 | 41 | list 42 | .add(apple) 43 | .render() 44 | .inject(sandbox) 45 | .setup(); 46 | 47 | expect(sandbox.querySelector('#' + apple._fmid)).toBeTruthy(); 48 | 49 | list.remove(apple); 50 | 51 | expect(sandbox.querySelector('#' + apple._fmid)).toBeFalsy(); 52 | }); 53 | 54 | test("Should *not* remove the child from the DOM if `fromDOM` option is false", function() { 55 | var sandbox = document.createElement('div'); 56 | var list = new helpers.Views.Layout(); 57 | var Apple = helpers.Views.Apple; 58 | var apple = new Apple(); 59 | 60 | list 61 | .add(apple, 1) 62 | .render() 63 | .setup() 64 | .inject(sandbox); 65 | 66 | expect(sandbox.querySelector('#' + apple._fmid)).toBeTruthy(); 67 | 68 | list.remove(apple, { fromDOM: false }); 69 | 70 | expect(sandbox.querySelector('#' + apple._fmid)).toBeTruthy(); 71 | }); 72 | 73 | test("Should unmount the view by default", function() { 74 | var list = new Layout({ 75 | children: { 76 | 1: new Apple() 77 | } 78 | }); 79 | 80 | var layoutSpy = jest.fn(); list.on('unmount', layoutSpy); 81 | var appleSpy = jest.fn(); list.module('apple').on('unmount', appleSpy); 82 | 83 | list.render().inject(sandbox).setup(); 84 | list.remove(); 85 | 86 | expect(layoutSpy).toBeCalled(); 87 | expect(appleSpy).toBeCalled(); 88 | }); 89 | 90 | test("Should not unmount the view if `fromDOM` option is false", function() { 91 | var list = new Layout({ 92 | children: { 93 | 1: new Apple() 94 | } 95 | }); 96 | 97 | var layoutSpy = jest.fn(); list.on('unmount', layoutSpy); 98 | var appleSpy = jest.fn(); list.module('apple').on('unmount', appleSpy); 99 | 100 | list.render().inject(sandbox).setup(); 101 | list.remove({fromDOM: false}); 102 | 103 | expect(layoutSpy).not.toBeCalled(); 104 | expect(appleSpy).not.toBeCalled(); 105 | }); 106 | 107 | test("Should remove itself if called with no arguments", function() { 108 | var list = new helpers.Views.Layout(); 109 | var Apple = helpers.Views.Apple; 110 | var apple = new Apple({ id: 'foo' }); 111 | 112 | list.add(apple); 113 | apple.remove(); 114 | 115 | expect(list.children.indexOf(apple)).toBe(-1); 116 | expect(list._ids.foo).toBeUndefined(); 117 | }); 118 | 119 | test("Should remove the reference back to the parent view", function() { 120 | var layout = new Layout(); 121 | var apple = new Apple({ slot: 1 }); 122 | 123 | layout.add(apple); 124 | 125 | expect(apple.parent).toBe(layout); 126 | 127 | layout.remove(apple); 128 | 129 | expect(apple.parent).toBeUndefined(); 130 | }); 131 | 132 | test("Should remove slot reference", function() { 133 | var layout = new Layout(); 134 | var apple = new Apple({ slot: 1 }); 135 | 136 | layout.add(apple); 137 | 138 | expect(layout.slots[1]).toBe(apple); 139 | 140 | layout.remove(apple); 141 | 142 | expect(layout.slots[1]).toBeUndefined(); 143 | }); 144 | 145 | test("Should not remove itself if first argument is undefined", function() { 146 | var layout = new Layout(); 147 | var apple = new Apple({ slot: 1 }); 148 | 149 | layout.add(apple); 150 | apple.remove(undefined); 151 | 152 | expect(layout.module('apple')).toBeTruthy(); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/tests/module.js: -------------------------------------------------------------------------------- 1 | var Backbone = require('backbone'); 2 | 3 | describe('View', function() { 4 | 5 | test("Should add any children passed into the constructor", function() { 6 | var children = [ 7 | { 8 | module: 'pear' 9 | }, 10 | { 11 | module: 'orange' 12 | } 13 | ]; 14 | 15 | var view = new fruitmachine({ 16 | module: 'apple', 17 | children: children 18 | }); 19 | 20 | expect(view.children.length).toBe(2); 21 | }); 22 | 23 | test("Should store a reference to the slot if passed", function() { 24 | var view = new fruitmachine({ 25 | module: 'apple', 26 | children: [ 27 | { 28 | module: 'pear', 29 | slot: 1 30 | }, 31 | { 32 | module: 'orange', 33 | slot: 2 34 | } 35 | ] 36 | }); 37 | 38 | expect(view.slots[1]).toBeTruthy(); 39 | expect(view.slots[2]).toBeTruthy(); 40 | }); 41 | 42 | test("Should store a reference to the slot if slot is passed as key of children object", function() { 43 | var view = new fruitmachine({ 44 | module: 'apple', 45 | children: { 46 | 1: { module: 'pear' }, 47 | 2: { module: 'orange' } 48 | } 49 | }); 50 | 51 | expect(view.slots[1]).toBeTruthy(); 52 | expect(view.slots[2]).toBeTruthy(); 53 | }); 54 | 55 | test("Should store a reference to the slot if the view is instantiated with a slot", function() { 56 | var apple = new Apple({ slot: 1 }); 57 | 58 | expect(apple.slot).toBe(1); 59 | }); 60 | 61 | test("Should prefer the slot on the children object in case of conflict", function() { 62 | var apple = new Apple({ slot: 1 }); 63 | var layout = new Layout({ 64 | children: { 65 | 2: apple 66 | } 67 | }); 68 | 69 | expect(layout.module('apple').slot).toBe('2'); 70 | }); 71 | 72 | test("Should create a model", function() { 73 | var view = new fruitmachine({ module: 'apple' }); 74 | expect(view.model instanceof fruitmachine.Model).toBe(true); 75 | }); 76 | 77 | test("Should adopt the fmid if passed", function() { 78 | var view = new fruitmachine({ fmid: '1234', module: 'apple' }); 79 | expect(view._fmid).toBe('1234'); 80 | }); 81 | 82 | test("Should fire an 'inflation' event on fm instance if instantiated with an fmid", function() { 83 | var spy = jest.fn(); 84 | 85 | fruitmachine.on('inflation', spy); 86 | 87 | var layout = new fruitmachine({ 88 | fmid: '1', 89 | module: 'layout', 90 | children: { 91 | 1: { 92 | fmid: '2', 93 | module: 'apple' 94 | } 95 | } 96 | }); 97 | 98 | expect(spy).toHaveBeenCalledTimes(2); 99 | }); 100 | 101 | test("Should fire an 'inflation' event on fm instance with the view as the first arg", function() { 102 | var spy = jest.fn(); 103 | 104 | fruitmachine.on('inflation', spy); 105 | 106 | var layout = new fruitmachine({ 107 | fmid: '1', 108 | module: 'layout', 109 | children: { 110 | 1: { 111 | fmid: '2', 112 | module: 'apple' 113 | } 114 | } 115 | }); 116 | 117 | expect(spy.mock.calls[0][0]).toBe(layout); 118 | expect(spy.mock.calls[1][0]).toBe(layout.module('apple')); 119 | }); 120 | 121 | test("Should fire an 'inflation' event on fm instance with the options as the second arg", function() { 122 | var spy = jest.fn(); 123 | var options = { 124 | fmid: '1', 125 | module: 'layout' 126 | }; 127 | 128 | fruitmachine.on('inflation', spy); 129 | 130 | var layout = new fruitmachine(options); 131 | expect(spy.mock.calls[0][1]).toEqual(options); 132 | }); 133 | 134 | test("Should be able to use Backbone models", function() { 135 | var orange = new Orange({ 136 | model: new Backbone.Model({ text: 'orange text' }) 137 | }); 138 | 139 | orange.render(); 140 | expect(orange.el.innerHTML.indexOf('orange text')).toBe(0); 141 | }); 142 | 143 | test("Should define a global default model", function() { 144 | var previous = fruitmachine.Module.prototype.Model; 145 | 146 | fruitmachine.Module.prototype.Model = Backbone.Model; 147 | 148 | var orange = new Orange({ 149 | model: { text: 'orange text' } 150 | }); 151 | 152 | orange.render(); 153 | expect(orange.model instanceof Backbone.Model).toBe(true); 154 | expect(orange.el.innerHTML.indexOf('orange text')).toBe(0); 155 | 156 | // Restore 157 | fruitmachine.Module.prototype.Model = previous; 158 | }); 159 | 160 | test("Should define a module default model", function() { 161 | var Berry = fruitmachine.define({ 162 | name: 'berry', 163 | Model: Backbone.Model 164 | }); 165 | 166 | var berry = new Berry({ model: { foo: 'bar' }}); 167 | 168 | expect(berry.model instanceof Backbone.Model).toBe(true); 169 | }); 170 | 171 | test.skip("Should not modify the options object", function() { 172 | var options = { 173 | classes: ['my class'] 174 | }; 175 | 176 | var orange = new Orange(options); 177 | orange.classes.push('added'); 178 | 179 | expect(['my class']).toBe(options.classes); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/tests/module.render.js: -------------------------------------------------------------------------------- 1 | 2 | describe('View#render()', function() { 3 | var viewToTest; 4 | 5 | beforeEach(function() { 6 | viewToTest = helpers.createView(); 7 | }); 8 | 9 | test("The master view should have an element post render.", function() { 10 | viewToTest.render(); 11 | expect(viewToTest.el).toBeDefined(); 12 | }); 13 | 14 | test("before render and render events should be fired", function() { 15 | var beforeRenderSpy = jest.fn(); 16 | var renderSpy = jest.fn(); 17 | viewToTest.on('before render', beforeRenderSpy); 18 | viewToTest.on('render', renderSpy); 19 | 20 | viewToTest.render(); 21 | expect(beforeRenderSpy.mock.invocationCallOrder[0]).toBeLessThan(renderSpy.mock.invocationCallOrder[0]); 22 | }); 23 | 24 | test("Data should be present in the generated markup.", function() { 25 | var text = 'some orange text'; 26 | var orange = new Orange({ 27 | model: { 28 | text: text 29 | } 30 | }); 31 | 32 | orange 33 | .render() 34 | .inject(sandbox); 35 | 36 | expect(orange.el.innerHTML).toEqual(text); 37 | }); 38 | 39 | test("Should be able to use Backbone models", function() { 40 | var orange = new Orange({ 41 | model: { 42 | text: 'orange text' 43 | } 44 | }); 45 | 46 | orange.render(); 47 | expect(orange.el.innerHTML).toEqual('orange text'); 48 | }); 49 | 50 | test("Child html should be present in the parent.", function() { 51 | var layout = new Layout(); 52 | var apple = new Apple(); 53 | 54 | layout 55 | .add(apple, 1) 56 | .render(); 57 | 58 | firstChild = layout.el.firstElementChild; 59 | expect(firstChild.classList.contains('apple')).toBe(true); 60 | }); 61 | 62 | test("Should be of the tag specified", function() { 63 | var apple = new Apple({ tag: 'ul' }); 64 | 65 | apple.render(); 66 | expect('UL').toEqual(apple.el.tagName); 67 | }); 68 | 69 | test("Should have classes on the element", function() { 70 | var apple = new Apple({ 71 | classes: ['foo', 'bar'] 72 | }); 73 | 74 | apple.render(); 75 | expect('apple foo bar').toEqual(apple.el.className); 76 | }); 77 | 78 | test("Should have an id attribute with the value of `fmid`", function() { 79 | var apple = new Apple({ 80 | classes: ['foo', 'bar'] 81 | }); 82 | 83 | apple.render(); 84 | 85 | expect(apple._fmid).toEqual(apple.el.id); 86 | }); 87 | 88 | test("Should have populated all child module.el properties", function() { 89 | var layout = new Layout({ 90 | children: { 91 | 1: { 92 | module: 'apple', 93 | children: { 94 | 1: { 95 | module: 'apple', 96 | children: { 97 | 1: { 98 | module: 'apple' 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | }); 106 | 107 | var apple1 = layout.module('apple'); 108 | var apple2 = apple1.module('apple'); 109 | var apple3 = apple2.module('apple'); 110 | 111 | layout.render(); 112 | 113 | expect(apple1.el).toBeTruthy(); 114 | expect(apple2.el).toBeTruthy(); 115 | expect(apple3.el).toBeTruthy(); 116 | }); 117 | 118 | test("The outer DOM node should be recycled between #renders", function() { 119 | var layout = new Layout({ 120 | children: { 121 | 1: { module: 'apple' } 122 | } 123 | }); 124 | layout.render(); 125 | layout.el.setAttribute('data-test', 'should-not-be-blown-away'); 126 | layout.module('apple').el.setAttribute('data-test', 'should-be-blown-away'); 127 | 128 | layout.render(); 129 | 130 | // The DOM node of the FM module that render is called on should be recycled 131 | expect(layout.el.getAttribute('data-test')).toEqual('should-not-be-blown-away'); 132 | 133 | // The DOM node of a child FM module to the one render is called on should not be recycled 134 | expect(layout.module('apple').el.getAttribute('data-test')).not.toEqual('should-be-blown-away'); 135 | }); 136 | 137 | test("Classes should be updated on render", function() { 138 | var layout = new Layout(); 139 | layout.render(); 140 | layout.classes = ['should-be-added']; 141 | layout.render(); 142 | expect(layout.el.className).toEqual('layout should-be-added'); 143 | }); 144 | 145 | test("Classes added through the DOM should persist between renders", function() { 146 | var layout = new Layout(); 147 | layout.render(); 148 | layout.el.classList.add('should-persist'); 149 | layout.render(); 150 | expect(layout.el.className).toEqual('layout should-persist'); 151 | }); 152 | 153 | test("Should fire unmount on children when rerendering", function() { 154 | var appleSpy = jest.fn(); 155 | var orangeSpy = jest.fn(); 156 | var pearSpy = jest.fn(); 157 | 158 | viewToTest.module('apple').on('unmount', appleSpy); 159 | viewToTest.module('orange').on('unmount', orangeSpy); 160 | viewToTest.module('pear').on('unmount', pearSpy); 161 | 162 | viewToTest.render(); 163 | expect(appleSpy).not.toHaveBeenCalled(); 164 | expect(orangeSpy).not.toHaveBeenCalled(); 165 | expect(pearSpy).not.toHaveBeenCalled(); 166 | 167 | viewToTest.render(); 168 | expect(appleSpy).toHaveBeenCalled(); 169 | expect(orangeSpy).toHaveBeenCalled(); 170 | expect(pearSpy).toHaveBeenCalled(); 171 | }); 172 | 173 | afterEach(function() { 174 | helpers.destroyView(); 175 | viewToTest = null; 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after the first failure 9 | // bail: false, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/var/folders/2z/qvbjmrlm8xjfgn006s69xpm80000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // Make calling deprecated APIs throw helpful error messages 46 | // errorOnDeprecated: false, 47 | 48 | // Force coverage collection from ignored files usin a array of glob patterns 49 | // forceCoverageMatch: [], 50 | 51 | // A path to a module which exports an async function that is triggered once before all test suites 52 | // globalSetup: null, 53 | 54 | // A path to a module which exports an async function that is triggered once after all test suites 55 | // globalTeardown: null, 56 | 57 | // A set of global variables that need to be available in all test environments 58 | // globals: {}, 59 | 60 | // An array of directory names to be searched recursively up from the requiring module's location 61 | moduleDirectories: [ 62 | "node_modules" 63 | ], 64 | 65 | // An array of file extensions your modules use 66 | // moduleFileExtensions: [ 67 | // "js", 68 | // "json", 69 | // "jsx", 70 | // "node" 71 | // ], 72 | 73 | // A map from regular expressions to module names that allow to stub out resources with a single module 74 | // moduleNameMapper: {}, 75 | 76 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 77 | // modulePathIgnorePatterns: [], 78 | 79 | // Activates notifications for test results 80 | // notify: false, 81 | 82 | // An enum that specifies notification mode. Requires { notify: true } 83 | // notifyMode: "always", 84 | 85 | // A preset that is used as a base for Jest's configuration 86 | // preset: null, 87 | 88 | // Run tests from one or more projects 89 | // projects: null, 90 | 91 | // Use this configuration option to add custom reporters to Jest 92 | // reporters: undefined, 93 | 94 | // Automatically reset mock state between every test 95 | // resetMocks: false, 96 | 97 | // Reset the module registry before running each individual test 98 | // resetModules: false, 99 | 100 | // A path to a custom resolver 101 | // resolver: null, 102 | 103 | // Automatically restore mock state between every test 104 | // restoreMocks: false, 105 | 106 | // The root directory that Jest should scan for tests and modules within 107 | // rootDir: null, 108 | 109 | // A list of paths to directories that Jest should use to search for files in 110 | // roots: [ 111 | // "" 112 | // ], 113 | 114 | // Allows you to use a custom runner instead of Jest's default test runner 115 | // runner: "jest-runner", 116 | 117 | // The paths to modules that run some code to configure or set up the testing environment before each test 118 | setupFiles: [ 119 | "./test/helpers.js" 120 | ], 121 | 122 | // The path to a module that runs some code to configure or set up the testing framework before each test 123 | // setupTestFrameworkScriptFile: null, 124 | 125 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 126 | // snapshotSerializers: [], 127 | 128 | // The test environment that will be used for testing 129 | // testEnvironment: "jest-environment-jsdom", 130 | 131 | // Options that will be passed to the testEnvironment 132 | // testEnvironmentOptions: {}, 133 | 134 | // Adds a location field to test results 135 | // testLocationInResults: false, 136 | 137 | // The glob patterns Jest uses to detect test files 138 | testMatch: [ 139 | "**/__tests__/**/*.js?(x)", 140 | "**/?(*.)+(spec|test).js?(x)", 141 | "**/test/tests/*.js" 142 | ], 143 | 144 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 145 | // testPathIgnorePatterns: [ 146 | // "/node_modules/" 147 | // ], 148 | 149 | // The regexp pattern Jest uses to detect test files 150 | // testRegex: "", 151 | 152 | // This option allows the use of a custom results processor 153 | // testResultsProcessor: null, 154 | 155 | // This option allows use of a custom test runner 156 | // testRunner: "jasmine2", 157 | 158 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 159 | // testURL: "http://localhost", 160 | 161 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 162 | // timers: "real", 163 | 164 | // A map from regular expressions to paths to transformers 165 | // transform: null, 166 | 167 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 168 | // transformIgnorePatterns: [ 169 | // "/node_modules/" 170 | // ], 171 | 172 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 173 | // unmockedModulePathPatterns: undefined, 174 | 175 | // Indicates whether each individual test should be reported during the run 176 | // verbose: null, 177 | 178 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 179 | // watchPathIgnorePatterns: [], 180 | 181 | // Whether to use watchman for file crawling 182 | // watchman: true, 183 | }; 184 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ### fruitmachine.define() 4 | 5 | slint browser:true, node:true, laxbreak:true 6 | ### fruitmachine.define() 7 | 8 | Defines a module. 9 | \nOptions: 10 | 11 | - `name {String}` the name of the module 12 | - `tag {String}` the tagName to use for the root element 13 | - `classes {Array}` a list of classes to add to the root element 14 | - `template {Function}` the template function to use when rendering 15 | - `helpers {Array}` a list of helpers to apply to the module 16 | - `initialize {Function}` custom logic to run when module instance created 17 | - `setup {Function}` custom logic to run when `.setup()` is called (directly or indirectly) 18 | - `teardown {Function}` custom logic to unbind/undo anything setup introduced (called on `.destroy()` and sometimes on `.setup()` to avoid double binding events) 19 | - `destroy {Function}` logic to permanently destroy all references 20 | 21 | ### Module#undefined 22 | 23 | shint browser:true, node:true 24 | ### Module#util 25 | 26 | Module Dependencies 27 | ### Module#exports() 28 | 29 | Exports 30 | ### Module#Module 31 | 32 | Module constructor 33 | \nOptions: 34 | 35 | - `id {String}` a unique id to query by 36 | - `model {Object|Model}` the data with which to associate this module 37 | - `tag {String}` tagName to use for the root element 38 | - `classes {Array}` list of classes to add to the root element 39 | - `template {Function}` a template to use for rendering 40 | - `helpers {Array}`a list of helper function to use on this module 41 | - `children {Object|Array}` list of child modules 42 | 43 | ### Module#add() 44 | 45 | Adds a child view(s) to another Module. 46 | \nOptions: 47 | 48 | - `at` The child index at which to insert 49 | - `inject` Injects the child's view element into the parent's 50 | - `slot` The slot at which to insert the child 51 | 52 | ### Module#remove() 53 | 54 | Removes a child view from 55 | its current Module contexts 56 | and also from the DOM unless 57 | otherwise stated. 58 | \nOptions: 59 | 60 | - `fromDOM` Whether the element should be removed from the DOM (default `true`) 61 | 62 | *Example:* 63 | 64 | // The following are equal 65 | // apple is removed from the 66 | // the view structure and DOM 67 | layout.remove(apple); 68 | apple.remove(); 69 | 70 | // Apple is removed from the 71 | // view structure, but not the DOM 72 | layout.remove(apple, { el: false }); 73 | apple.remove({ el: false }); 74 | 75 | ### Module#id() 76 | 77 | Returns a decendent module 78 | by id, or if called with no 79 | arguments, returns this view's id. 80 | \n*Example:* 81 | 82 | myModule.id(); 83 | //=> 'my_view_id' 84 | 85 | myModule.id('my_other_views_id'); 86 | //=> Module 87 | 88 | ### Module#module() 89 | 90 | Returns the first descendent 91 | Module with the passed module type. 92 | If called with no arguments the 93 | Module's own module type is returned. 94 | \n*Example:* 95 | 96 | // Assuming 'myModule' has 3 descendent 97 | // views with the module type 'apple' 98 | 99 | myModule.modules('apple'); 100 | //=> Module 101 | 102 | ### Module#modules() 103 | 104 | Returns a list of descendent 105 | Modules that match the module 106 | type given (Similar to 107 | Element.querySelectorAll();). 108 | \n*Example:* 109 | 110 | // Assuming 'myModule' has 3 descendent 111 | // views with the module type 'apple' 112 | 113 | myModule.modules('apple'); 114 | //=> [ Module, Module, Module ] 115 | 116 | ### Module#each() 117 | 118 | Calls the passed function 119 | for each of the view's 120 | children. 121 | \n*Example:* 122 | 123 | myModule.each(function(child) { 124 | // Do stuff with each child view... 125 | }); 126 | 127 | ### Module#toHTML() 128 | 129 | Templates the view, including 130 | any descendent views returning 131 | an html string. All data in the 132 | views model is made accessible 133 | to the template. 134 | \nChild views are printed into the 135 | parent template by `id`. Alternatively 136 | children can be iterated over a a list 137 | and printed with `{{{child}}}}`. 138 | 139 | *Example:* 140 | 141 |
    {{{}}}
    142 |
    {{{}}}
    143 | 144 | // or 145 | 146 | {{#children}} 147 | {{{child}}} 148 | {{/children}} 149 | 150 | ### Module#_innerHTML() 151 | 152 | Get the view's innerHTML 153 | 154 | ### Module#render() 155 | 156 | Renders the view and replaces 157 | the `view.el` with a freshly 158 | rendered node. 159 | \nFires a `render` event on the view. 160 | 161 | ### Module#setup() 162 | 163 | Sets up a view and all descendent 164 | views. 165 | \nSetup will be aborted if no `view.el` 166 | is found. If a view is already setup, 167 | teardown is run first to prevent a 168 | view being setup twice. 169 | 170 | Your custom `setup()` method is called 171 | 172 | Options: 173 | 174 | - `shallow` Does not recurse when `true` (default `false`) 175 | 176 | ### Module#teardown() 177 | 178 | Tearsdown a view and all descendent 179 | views that have been setup. 180 | \nYour custom `teardown` method is 181 | called and a `teardown` event is fired. 182 | 183 | Options: 184 | 185 | - `shallow` Does not recurse when `true` (default `false`) 186 | 187 | ### Module#destroy() 188 | 189 | Completely destroys a view. This means 190 | a view is torn down, removed from it's 191 | current layout context and removed 192 | from the DOM. 193 | \nYour custom `destroy` method is 194 | called and a `destroy` event is fired. 195 | 196 | NOTE: `.remove()` is only run on the view 197 | that `.destroy()` is directly called on. 198 | 199 | Options: 200 | 201 | - `fromDOM` Whether the view should be removed from DOM (default `true`) 202 | 203 | ### Module#empty() 204 | 205 | Destroys all children. 206 | \nIs this needed? 207 | 208 | ### Module#mount() 209 | 210 | Associate the view with an element. 211 | Provide events and lifecycle methods 212 | to fire when the element is newly 213 | associated. 214 | 215 | ### Module#inject() 216 | 217 | Empties the destination element 218 | and appends the view into it. 219 | 220 | ### Module#appendTo() 221 | 222 | Appends the view element into 223 | the destination element. 224 | 225 | ### Module#insertBefore() 226 | 227 | Inserts the view element before the 228 | given child of the destination element. 229 | 230 | ### Module#toJSON() 231 | 232 | Returns a JSON represention of 233 | a FruitMachine Module. This can 234 | be generated serverside and 235 | passed into new FruitMachine(json) 236 | to inflate serverside rendered 237 | views. 238 | 239 | ### Module#events 240 | 241 | Module Dependencies 242 | ### Module#listenerMap 243 | 244 | Local vars 245 | ### Module#on() 246 | 247 | Registers a event listener. 248 | 249 | ### Module#off() 250 | 251 | Unregisters a event listener. 252 | 253 | ### Module#fire() 254 | 255 | Fires an event on a view. 256 | 257 | -------------------------------------------------------------------------------- /examples/lib/delegate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a circularly-linked list 3 | * 4 | * Adapted from original version by James Coglan. 5 | * 6 | * @fileOverview 7 | * @codingstandard ftlabs-jsv2 8 | * @copyright The Financial Times Limited [All Rights Reserved] 9 | */ 10 | 11 | this.CircularList = (function () { 12 | 'use strict'; 13 | 14 | 15 | /** 16 | * @constructor 17 | */ 18 | function CircularList() { 19 | 20 | 21 | /** 22 | * The length of the linked list 23 | * 24 | * @type number 25 | */ 26 | this.length = 0; 27 | 28 | 29 | /** 30 | * The first item in the linked list 31 | * 32 | * @type Object 33 | */ 34 | this.first = null; 35 | 36 | 37 | /** 38 | * The last item in the linked list 39 | * 40 | * @type Object 41 | */ 42 | this.last = null; 43 | } 44 | 45 | 46 | /** 47 | * Explicit item object to allow items to belong to more than linked list at a time 48 | * 49 | * To hold a reference to a CircularList.Item within a completely different CircularList the CircularList.Item should be passed as the data to a new CircularList.Item to be used in the new CircularList. 50 | * If you don't wrap the reference in a new Item, then if you append an already existing reference to a different CircularList the behaviour is undefined. 51 | * 52 | * @example 53 | * myList.append(new CircularList.Item(someObject)); 54 | * 55 | * @constructor 56 | * @param {Object} data 57 | */ 58 | CircularList.Item = function (data) { 59 | this.prev = null; 60 | this.next = null; 61 | this.list = null; 62 | this.data = data; 63 | }; 64 | 65 | 66 | /** 67 | * Append an object to the linked list 68 | * 69 | * @param {Object} item The item to append 70 | */ 71 | CircularList.prototype.append = function (item) { 72 | if (this.first === null) { 73 | item.prev = item; 74 | item.next = item; 75 | this.first = item; 76 | this.last = item; 77 | } else { 78 | item.prev = this.last; 79 | item.next = this.first; 80 | this.first.prev = item; 81 | this.last.next = item; 82 | this.last = item; 83 | } 84 | 85 | item.list = this; 86 | this.length++; 87 | }; 88 | 89 | 90 | /** 91 | * Remove an item from the linked list 92 | * 93 | * @param {Object} item The item to remove 94 | */ 95 | CircularList.prototype.remove = function (item) { 96 | 97 | // Exit early if the item isn't in the list 98 | if (!this.length || this !== item.list) { 99 | return; 100 | } 101 | 102 | if (this.length > 1) { 103 | item.prev.next = item.next; 104 | item.next.prev = item.prev; 105 | 106 | if (item === this.first) { 107 | this.first = item.next; 108 | } 109 | 110 | if (item === this.last) { 111 | this.last = item.prev; 112 | } 113 | } else { 114 | this.first = null; 115 | this.last = null; 116 | } 117 | 118 | item.prev = null; 119 | item.next = null; 120 | this.length--; 121 | }; 122 | 123 | 124 | /** 125 | * Convert the linked list to an Array 126 | * 127 | * The first item in the list is the first item in the array. 128 | * 129 | * @return {Array} 130 | */ 131 | CircularList.prototype.toArray = function () { 132 | var i, item, array, length = this.length; 133 | 134 | array = new Array(length); 135 | item = this.first; 136 | 137 | for (i = 0; i < length; i++) { 138 | array[i] = item; 139 | item = item.next; 140 | } 141 | 142 | return array; 143 | }; 144 | 145 | 146 | /** 147 | * Insert an item after one already in the linked list 148 | * 149 | * @param {Object} item The reference item 150 | * @param {Object} newItem The item to insert 151 | */ 152 | CircularList.prototype.insertAfter = function (item, newItem) { 153 | newItem.prev = item; 154 | newItem.next = item.next; 155 | item.next.prev = newItem; 156 | item.next = newItem; 157 | 158 | if (newItem.prev === this.last) { 159 | this.last = newItem; 160 | } 161 | 162 | newItem.list = this; 163 | this.length++; 164 | }; 165 | 166 | return CircularList; 167 | 168 | }()); 169 | 170 | /** 171 | * Create a DOM event delegator 172 | * 173 | * @fileOverview 174 | * @codingstandard ftlabs-jsv2 175 | * @copyright The Financial Times Limited [All Rights Reserved] 176 | */ 177 | 178 | this.Delegate = (function(that) { 179 | "use strict"; 180 | 181 | var 182 | 183 | 184 | /** 185 | * Event listener separator 186 | * 187 | * @private 188 | * @type string 189 | */ 190 | SEPARATOR = ' ', 191 | 192 | 193 | /** 194 | * Event object property used to signal that event should be ignored by handler 195 | * 196 | * @private 197 | * @type string 198 | */ 199 | EVENT_IGNORE = 'ftLabsDelegateIgnore', 200 | 201 | 202 | /** 203 | * Circular list constructor 204 | * 205 | * @private 206 | * @type function() 207 | */ 208 | CircularList = that.CircularList, 209 | 210 | 211 | /** 212 | * Whether tag names are case sensitive (as in XML or XHTML documents) 213 | * 214 | * @type boolean 215 | */ 216 | tagsCaseSensitive = document.createElement('i').tagName === 'i', 217 | 218 | 219 | /** 220 | * Check whether an element matches a generic selector 221 | * 222 | * @private 223 | * @type function() 224 | * @param {string} selector A CSS selector 225 | */ 226 | matches = (function(p) { 227 | return (p.matchesSelector || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || p.oMatchesSelector); 228 | }(HTMLElement.prototype)), 229 | 230 | 231 | /** 232 | * Check whether an element matches a tag selector 233 | * 234 | * Tags are NOT case-sensitive, except in XML (and XML-based languages such as XHTML). 235 | * 236 | * @private 237 | * @param {string} tagName The tag name to test against 238 | * @param {Element} element The element to test with 239 | */ 240 | matchesTag = function(tagName, element) { 241 | return tagName === element.tagName; 242 | }, 243 | 244 | 245 | /** 246 | * Check whether the ID of the element in 'this' matches the given ID 247 | * 248 | * IDs are case-sensitive. 249 | * 250 | * @private 251 | * @param {string} id The ID to test against 252 | * @param {Element} element The element to test with 253 | */ 254 | matchesId = function(id, element) { 255 | return id === element.id; 256 | }; 257 | 258 | 259 | /** 260 | * Fire a listener on a target 261 | * 262 | * @private 263 | * @param {Event} event 264 | * @param {Node} target 265 | * @param {Object} listener 266 | */ 267 | function fire(event, target, listener) { 268 | var returned, oldData; 269 | 270 | if (listener.d !== null) { 271 | oldData = event.data; 272 | event.data = listener.d; 273 | returned = listener.h.call(target, event, target); 274 | event.data = oldData; 275 | } else { 276 | returned = listener.h.call(target, event, target); 277 | } 278 | 279 | return returned; 280 | } 281 | 282 | 283 | /** 284 | * Internal function proxied by Delegate#on 285 | * 286 | * @private 287 | * @param {Object} lisenerList 288 | * @param {Node|DOMWindow} root 289 | * @param {Event} event 290 | */ 291 | function handle(listenerList, root, event) { 292 | var listener, returned, specificList, target; 293 | 294 | if (event[EVENT_IGNORE] === true) { 295 | return; 296 | } 297 | 298 | target = event.target; 299 | if (target.nodeType === Node.TEXT_NODE) { 300 | target = target.parentNode; 301 | } 302 | specificList = listenerList[event.type]; 303 | 304 | // If the fire function actually causes the specific list to be destroyed, 305 | // Need check that the specific list is still populated 306 | while (target && specificList.length > 0) { 307 | listener = specificList.first; 308 | do { 309 | 310 | // Check for match and fire the event if there's one 311 | // TODO:MCG:20120117: Need a way to check if event#stopImmediateProgagation was called. If so, break both loops. 312 | if (listener.m.call(target, listener.p, target)) { 313 | returned = fire(event, target, listener); 314 | } 315 | 316 | // Stop propagation to subsequent callbacks if the callback returned false 317 | if (returned === false) { 318 | event[EVENT_IGNORE] = true; 319 | return; 320 | } 321 | 322 | listener = listener.next; 323 | 324 | // If the fire function actually causes the specific list object to be destroyed, 325 | // need a way of getting out of here so check listener is set 326 | } while (listener !== specificList.first && listener); 327 | 328 | // TODO:MCG:20120117: Need a way to check if event#stopProgagation was called. If so, break looping through the DOM. 329 | // Stop if the delegation root has been reached 330 | if (target === root) { 331 | break; 332 | } 333 | 334 | target = target.parentElement; 335 | } 336 | } 337 | 338 | 339 | /** 340 | * Internal function proxied by Delegate#on 341 | * 342 | * @private 343 | * @param {Delegate} that 344 | * @param {Object} listenerList 345 | * @param {Node|DOMWindow} root 346 | */ 347 | function on(that, listenerList, root, eventType, selector, eventData, handler) { 348 | var matcher, matcherParam; 349 | 350 | if (!eventType) { 351 | throw new TypeError('Invalid event type: ' + eventType); 352 | } 353 | 354 | if (!selector) { 355 | throw new TypeError('Invalid selector: ' + selector); 356 | } 357 | 358 | // Support a separated list of event types 359 | if (eventType.indexOf(SEPARATOR) !== -1) { 360 | eventType.split(SEPARATOR).forEach(function(eventType) { 361 | on.call(that, that, listenerList, root, eventType, selector, eventData, handler); 362 | }); 363 | 364 | return; 365 | } 366 | 367 | if (handler === undefined) { 368 | handler = eventData; 369 | eventData = null; 370 | 371 | // Normalise undefined eventData to null 372 | } else if (eventData === undefined) { 373 | eventData = null; 374 | } 375 | 376 | if (typeof handler !== 'function') { 377 | throw new TypeError("Handler must be a type of Function"); 378 | } 379 | 380 | // Add master handler for type if not created yet 381 | if (!listenerList[eventType]) { 382 | root.addEventListener(eventType, that.handle, (eventType === 'error')); 383 | listenerList[eventType] = new CircularList(); 384 | } 385 | 386 | // Compile a matcher for the given selector 387 | if (/^[a-z]+$/i.test(selector)) { 388 | if (!tagsCaseSensitive) { 389 | matcherParam = selector.toUpperCase(); 390 | } else { 391 | matcherParam = selector; 392 | } 393 | 394 | matcher = matchesTag; 395 | } else if (/^#[a-z0-9\-_]+$/i.test(selector)) { 396 | matcherParam = selector.slice(1); 397 | matcher = matchesId; 398 | } else { 399 | matcherParam = selector; 400 | matcher = matches; 401 | } 402 | 403 | // Add to the list of listeners 404 | listenerList[eventType].append({ 405 | s: selector, 406 | d: eventData, 407 | h: handler, 408 | m: matcher, 409 | p: matcherParam 410 | }); 411 | } 412 | 413 | 414 | /** 415 | * Internal function proxied by Delegate#off 416 | * 417 | * @private 418 | * @param {Delegate} that 419 | * @param {Object} listenerList 420 | * @param {Node|DOMWindow} root 421 | */ 422 | function off(that, listenerList, root, eventType, selector, handler) { 423 | var listener, nextListener, firstListener, specificList, singleEventType; 424 | 425 | if (!eventType) { 426 | for (singleEventType in listenerList) { 427 | if (listenerList.hasOwnProperty(singleEventType)) { 428 | off.call(that, that, listenerList, root, singleEventType, selector, handler); 429 | } 430 | } 431 | return; 432 | } 433 | specificList = listenerList[eventType]; 434 | 435 | if (!specificList) { 436 | return; 437 | } 438 | 439 | // Support a separated list of event types 440 | if (eventType.indexOf(SEPARATOR) !== -1) { 441 | eventType.split(SEPARATOR).forEach(function(eventType) { 442 | off.call(that, that, listenerList, root, eventType, selector, handler); 443 | }); 444 | return; 445 | } 446 | 447 | // Remove only parameter matches if specified 448 | listener = firstListener = specificList.first; 449 | do { 450 | if ((!selector || selector === listener.s) && (!handler || handler === listener.h)) { 451 | 452 | // listener.next will be undefined after listener is removed, so save a reference here 453 | nextListener = listener.next; 454 | specificList.remove(listener); 455 | listener = nextListener; 456 | } else { 457 | listener = listener.next; 458 | } 459 | } while (listener && listener !== firstListener); 460 | 461 | // All listeners removed 462 | if (!specificList.length) { 463 | delete listenerList[eventType]; 464 | 465 | // Remove the main handler 466 | root.removeEventListener(eventType, that.handle, false); 467 | } 468 | } 469 | 470 | 471 | /** 472 | * DOM event delegator 473 | * 474 | * The delegator will listen for events that bubble up to the root node. 475 | * 476 | * @constructor 477 | * @param {Node|DOMWindow|string} root The root node, a window object or a selector string 478 | */ 479 | function Delegate(root) { 480 | var 481 | 482 | 483 | /** 484 | * Keep a reference to the current instance 485 | * 486 | * @internal 487 | * @type Delegate 488 | */ 489 | that = this, 490 | 491 | 492 | /** 493 | * Maintain a list of listeners, indexed by event name 494 | * 495 | * @internal 496 | * @type Object 497 | */ 498 | listenerList = {}; 499 | 500 | if (typeof root === 'string') { 501 | root = document.querySelector(root); 502 | } 503 | 504 | if (!root || !root.addEventListener) { 505 | throw new TypeError('Root node not specified'); 506 | } 507 | 508 | 509 | /** 510 | * Attach a handler to one event for all elements that match the selector, now or in the future 511 | * 512 | * The handler function receives three arguments: the DOM event object, the node that matched the selector while the event was bubbling 513 | * and a reference to itself. Within the handler, 'this' is equal to the second argument. 514 | * The node that actually received the event can be accessed via 'event.target'. 515 | * 516 | * @param {string} eventType Listen for these events (in a space-separated list) 517 | * @param {string} selector Only handle events on elements matching this selector 518 | * @param {Object} [eventData] If this parameter is not specified, the third parameter must be the handler 519 | * @param {function()} handler Handler function - event data passed here will be in event.data 520 | * @returns {Delegate} This method is chainable 521 | */ 522 | this.on = function() { 523 | Array.prototype.unshift.call(arguments, that, listenerList, root); 524 | on.apply(that, arguments); 525 | return this; 526 | }; 527 | 528 | 529 | /** 530 | * Remove an event handler for elements that match the selector, forever 531 | * 532 | * @param {string} eventType Remove handlers for events matching this type, considering the other parameters 533 | * @param {string} [selector] If this parameter is omitted, only handlers which match the other two will be removed 534 | * @param {function()} [handler] If this parameter is omitted, only handlers which match the previous two will be removed 535 | * @returns {Delegate} This method is chainable 536 | */ 537 | this.off = function() { 538 | Array.prototype.unshift.call(arguments, that, listenerList, root); 539 | off.apply(that, arguments); 540 | return this; 541 | }; 542 | 543 | 544 | /** 545 | * Handle an arbitrary event 546 | * 547 | * @private 548 | * @param {Event} event 549 | */ 550 | this.handle = function(event) { 551 | handle.call(that, listenerList, root, event); 552 | }; 553 | } 554 | 555 | return Delegate; 556 | 557 | }(this)); -------------------------------------------------------------------------------- /examples/lib/hogan.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2011 Twitter, Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | var HoganTemplate = (function () { 17 | 18 | function constructor(text) { 19 | this.text = text; 20 | } 21 | 22 | constructor.prototype = { 23 | 24 | // render: replaced by generated code. 25 | r: function (context, partials, indent) { return ''; }, 26 | 27 | // variable escaping 28 | v: hoganEscape, 29 | 30 | render: function render(context, partials, indent) { 31 | return this.r(context, partials, indent); 32 | }, 33 | 34 | // tries to find a partial in the curent scope and render it 35 | rp: function(name, context, partials, indent) { 36 | var partial = partials[name]; 37 | 38 | if (!partial) { 39 | return ''; 40 | } 41 | 42 | return partial.r(context, partials, indent); 43 | }, 44 | 45 | // render a section 46 | rs: function(context, partials, section) { 47 | var buf = '', 48 | tail = context[context.length - 1]; 49 | 50 | if (!isArray(tail)) { 51 | return buf = section(context, partials); 52 | } 53 | 54 | for (var i = 0; i < tail.length; i++) { 55 | context.push(tail[i]); 56 | buf += section(context, partials); 57 | context.pop(); 58 | } 59 | 60 | return buf; 61 | }, 62 | 63 | // maybe start a section 64 | s: function(val, ctx, partials, inverted, start, end, tags) { 65 | var pass; 66 | 67 | if (isArray(val) && val.length === 0) { 68 | return false; 69 | } 70 | 71 | if (!inverted && typeof val == 'function') { 72 | val = this.ls(val, ctx, partials, start, end, tags); 73 | } 74 | 75 | pass = (val === '') || !!val; 76 | 77 | if (!inverted && pass && ctx) { 78 | ctx.push((typeof val == 'object') ? val : ctx[ctx.length - 1]); 79 | } 80 | 81 | return pass; 82 | }, 83 | 84 | // find values with dotted names 85 | d: function(key, ctx, partials, returnFound) { 86 | 87 | var names = key.split('.'), 88 | val = this.f(names[0], ctx, partials, returnFound), 89 | cx = null; 90 | 91 | if (key === '.' && isArray(ctx[ctx.length - 2])) { 92 | return ctx[ctx.length - 1]; 93 | } 94 | 95 | for (var i = 1; i < names.length; i++) { 96 | if (val && typeof val == 'object' && names[i] in val) { 97 | cx = val; 98 | val = val[names[i]]; 99 | } else { 100 | val = ''; 101 | } 102 | } 103 | 104 | if (returnFound && !val) { 105 | return false; 106 | } 107 | 108 | if (!returnFound && typeof val == 'function') { 109 | ctx.push(cx); 110 | val = this.lv(val, ctx, partials); 111 | ctx.pop(); 112 | } 113 | 114 | return val; 115 | }, 116 | 117 | // find values with normal names 118 | f: function(key, ctx, partials, returnFound) { 119 | var val = false, 120 | v = null, 121 | found = false; 122 | 123 | for (var i = ctx.length - 1; i >= 0; i--) { 124 | v = ctx[i]; 125 | if (v && typeof v == 'object' && key in v) { 126 | val = v[key]; 127 | found = true; 128 | break; 129 | } 130 | } 131 | 132 | if (!found) { 133 | return (returnFound) ? false : ""; 134 | } 135 | 136 | if (!returnFound && typeof val == 'function') { 137 | val = this.lv(val, ctx, partials); 138 | } 139 | 140 | return val; 141 | }, 142 | 143 | // higher order templates 144 | ho: function(val, cx, partials, text, tags) { 145 | var t = val.call(cx, text, function(t) { 146 | return Hogan.compile(t, {delimiters: tags}).render(cx, partials); 147 | }); 148 | var s = Hogan.compile(t.toString(), {delimiters: tags}).render(cx, partials); 149 | this.b = s; 150 | return false; 151 | }, 152 | 153 | // higher order template result buffer 154 | b: '', 155 | 156 | // lambda replace section 157 | ls: function(val, ctx, partials, start, end, tags) { 158 | var cx = ctx[ctx.length - 1], 159 | t = val.call(cx); 160 | 161 | if (val.length > 0) { 162 | return this.ho(val, cx, partials, this.text.substring(start, end), tags); 163 | } 164 | 165 | if (typeof t == 'function') { 166 | return this.ho(t, cx, partials, this.text.substring(start, end), tags); 167 | } 168 | 169 | return t; 170 | }, 171 | 172 | // lambda replace variable 173 | lv: function(val, ctx, partials) { 174 | var cx = ctx[ctx.length - 1]; 175 | return Hogan.compile(val.call(cx).toString()).render(cx, partials); 176 | } 177 | 178 | }; 179 | 180 | var rAmp = /&/g, 181 | rLt = //g, 183 | rApos =/\'/g, 184 | rQuot = /\"/g, 185 | hChars =/[&<>\"\']/; 186 | 187 | function hoganEscape(str) { 188 | str = String(str === null ? '' : str); 189 | return hChars.test(str) ? 190 | str 191 | .replace(rAmp,'&') 192 | .replace(rLt,'<') 193 | .replace(rGt,'>') 194 | .replace(rApos,''') 195 | .replace(rQuot, '"') : 196 | str; 197 | } 198 | 199 | var isArray = Array.isArray || function(a) { 200 | return Object.prototype.toString.call(a) === '[object Array]'; 201 | }; 202 | 203 | return constructor; 204 | 205 | })(); 206 | 207 | var Hogan = (function () { 208 | 209 | // Setup regex assignments 210 | // remove whitespace according to Mustache spec 211 | var rIsWhitespace = /\S/, 212 | rQuot = /\"/g, 213 | rNewline = /\n/g, 214 | rCr = /\r/g, 215 | rSlash = /\\/g, 216 | tagTypes = { 217 | '#': 1, '^': 2, '/': 3, '!': 4, '>': 5, 218 | '<': 6, '=': 7, '_v': 8, '{': 9, '&': 10 219 | }; 220 | 221 | function scan(text, delimiters) { 222 | var len = text.length, 223 | IN_TEXT = 0, 224 | IN_TAG_TYPE = 1, 225 | IN_TAG = 2, 226 | state = IN_TEXT, 227 | tagType = null, 228 | tag = null, 229 | buf = '', 230 | tokens = [], 231 | seenTag = false, 232 | i = 0, 233 | lineStart = 0, 234 | otag = '{{', 235 | ctag = '}}'; 236 | 237 | function addBuf() { 238 | if (buf.length > 0) { 239 | tokens.push(new String(buf)); 240 | buf = ''; 241 | } 242 | } 243 | 244 | function lineIsWhitespace() { 245 | var isAllWhitespace = true; 246 | for (var j = lineStart; j < tokens.length; j++) { 247 | isAllWhitespace = 248 | (tokens[j].tag && tagTypes[tokens[j].tag] < tagTypes['_v']) || 249 | (!tokens[j].tag && tokens[j].match(rIsWhitespace) === null); 250 | if (!isAllWhitespace) { 251 | return false; 252 | } 253 | } 254 | 255 | return isAllWhitespace; 256 | } 257 | 258 | function filterLine(haveSeenTag, noNewLine) { 259 | addBuf(); 260 | 261 | if (haveSeenTag && lineIsWhitespace()) { 262 | for (var j = lineStart, next; j < tokens.length; j++) { 263 | if (!tokens[j].tag) { 264 | if ((next = tokens[j+1]) && next.tag == '>') { 265 | // set indent to token value 266 | next.indent = tokens[j].toString() 267 | } 268 | tokens.splice(j, 1); 269 | } 270 | } 271 | } else if (!noNewLine) { 272 | tokens.push({tag:'\n'}); 273 | } 274 | 275 | seenTag = false; 276 | lineStart = tokens.length; 277 | } 278 | 279 | function changeDelimiters(text, index) { 280 | var close = '=' + ctag, 281 | closeIndex = text.indexOf(close, index), 282 | delimiters = trim( 283 | text.substring(text.indexOf('=', index) + 1, closeIndex) 284 | ).split(' '); 285 | 286 | otag = delimiters[0]; 287 | ctag = delimiters[1]; 288 | 289 | return closeIndex + close.length - 1; 290 | } 291 | 292 | if (delimiters) { 293 | delimiters = delimiters.split(' '); 294 | otag = delimiters[0]; 295 | ctag = delimiters[1]; 296 | } 297 | 298 | for (i = 0; i < len; i++) { 299 | if (state == IN_TEXT) { 300 | if (tagChange(otag, text, i)) { 301 | --i; 302 | addBuf(); 303 | state = IN_TAG_TYPE; 304 | } else { 305 | if (text.charAt(i) == '\n') { 306 | filterLine(seenTag); 307 | } else { 308 | buf += text.charAt(i); 309 | } 310 | } 311 | } else if (state == IN_TAG_TYPE) { 312 | i += otag.length - 1; 313 | tag = tagTypes[text.charAt(i + 1)]; 314 | tagType = tag ? text.charAt(i + 1) : '_v'; 315 | if (tagType == '=') { 316 | i = changeDelimiters(text, i); 317 | state = IN_TEXT; 318 | } else { 319 | if (tag) { 320 | i++; 321 | } 322 | state = IN_TAG; 323 | } 324 | seenTag = i; 325 | } else { 326 | if (tagChange(ctag, text, i)) { 327 | tokens.push({tag: tagType, n: trim(buf), otag: otag, ctag: ctag, 328 | i: (tagType == '/') ? seenTag - ctag.length : i + otag.length}); 329 | buf = ''; 330 | i += ctag.length - 1; 331 | state = IN_TEXT; 332 | if (tagType == '{') { 333 | i++; 334 | } 335 | } else { 336 | buf += text.charAt(i); 337 | } 338 | } 339 | } 340 | 341 | filterLine(seenTag, true); 342 | 343 | return tokens; 344 | } 345 | 346 | function trim(s) { 347 | if (s.trim) { 348 | return s.trim(); 349 | } 350 | 351 | return s.replace(/^\s*|\s*$/g, ''); 352 | } 353 | 354 | function tagChange(tag, text, index) { 355 | if (text.charAt(index) != tag.charAt(0)) { 356 | return false; 357 | } 358 | 359 | for (var i = 1, l = tag.length; i < l; i++) { 360 | if (text.charAt(index + i) != tag.charAt(i)) { 361 | return false; 362 | } 363 | } 364 | 365 | return true; 366 | } 367 | 368 | function buildTree(tokens, kind, stack, customTags) { 369 | var instructions = [], 370 | opener = null, 371 | token = null; 372 | 373 | while (tokens.length > 0) { 374 | token = tokens.shift(); 375 | if (token.tag == '#' || token.tag == '^' || isOpener(token, customTags)) { 376 | stack.push(token); 377 | token.nodes = buildTree(tokens, token.tag, stack, customTags); 378 | instructions.push(token); 379 | } else if (token.tag == '/') { 380 | if (stack.length === 0) { 381 | throw new Error('Closing tag without opener: /' + token.n); 382 | } 383 | opener = stack.pop(); 384 | if (token.n != opener.n && !isCloser(token.n, opener.n, customTags)) { 385 | throw new Error('Nesting error: ' + opener.n + ' vs. ' + token.n); 386 | } 387 | opener.end = token.i; 388 | return instructions; 389 | } else { 390 | instructions.push(token); 391 | } 392 | } 393 | 394 | if (stack.length > 0) { 395 | throw new Error('missing closing tag: ' + stack.pop().n); 396 | } 397 | 398 | return instructions; 399 | } 400 | 401 | function isOpener(token, tags) { 402 | for (var i = 0, l = tags.length; i < l; i++) { 403 | if (tags[i].o == token.n) { 404 | token.tag = '#'; 405 | return true; 406 | } 407 | } 408 | } 409 | 410 | function isCloser(close, open, tags) { 411 | for (var i = 0, l = tags.length; i < l; i++) { 412 | if (tags[i].c == close && tags[i].o == open) { 413 | return true; 414 | } 415 | } 416 | } 417 | 418 | function generate(tree, text, options) { 419 | var code = 'i = i || "";var c = [cx];var b = i + "";var _ = this;' 420 | + walk(tree) 421 | + 'return b;'; 422 | 423 | if (options.asString) { 424 | return 'function(cx,p,i){' + code + ';}'; 425 | } 426 | 427 | var template = new HoganTemplate(text); 428 | template.r = new Function('cx', 'p', 'i', code); 429 | return template; 430 | } 431 | 432 | function esc(s) { 433 | return s.replace(rSlash, '\\\\') 434 | .replace(rQuot, '\\\"') 435 | .replace(rNewline, '\\n') 436 | .replace(rCr, '\\r'); 437 | } 438 | 439 | function chooseMethod(s) { 440 | return (~s.indexOf('.')) ? 'd' : 'f'; 441 | } 442 | 443 | function walk(tree) { 444 | var code = ''; 445 | for (var i = 0, l = tree.length; i < l; i++) { 446 | var tag = tree[i].tag; 447 | if (tag == '#') { 448 | code += section(tree[i].nodes, tree[i].n, chooseMethod(tree[i].n), 449 | tree[i].i, tree[i].end, tree[i].otag + " " + tree[i].ctag); 450 | } else if (tag == '^') { 451 | code += invertedSection(tree[i].nodes, tree[i].n, 452 | chooseMethod(tree[i].n)); 453 | } else if (tag == '<' || tag == '>') { 454 | code += partial(tree[i]); 455 | } else if (tag == '{' || tag == '&') { 456 | code += tripleStache(tree[i].n, chooseMethod(tree[i].n)); 457 | } else if (tag == '\n') { 458 | code += text('"\\n"' + (tree.length-1 == i ? '' : ' + i')); 459 | } else if (tag == '_v') { 460 | code += variable(tree[i].n, chooseMethod(tree[i].n)); 461 | } else if (tag === undefined) { 462 | code += text('"' + esc(tree[i]) + '"'); 463 | } 464 | } 465 | return code; 466 | } 467 | 468 | function section(nodes, id, method, start, end, tags) { 469 | return 'if(_.s(_.' + method + '("' + esc(id) + '",c,p,1),' + 470 | 'c,p,0,' + start + ',' + end + ', "' + tags + '")){' + 471 | 'b += _.rs(c,p,' + 472 | 'function(c,p){ var b = "";' + 473 | walk(nodes) + 474 | 'return b;});c.pop();}' + 475 | 'else{b += _.b; _.b = ""};'; 476 | } 477 | 478 | function invertedSection(nodes, id, method) { 479 | return 'if (!_.s(_.' + method + '("' + esc(id) + '",c,p,1),c,p,1,0,0,"")){' + 480 | walk(nodes) + 481 | '};'; 482 | } 483 | 484 | function partial(tok) { 485 | return 'b += _.rp("' + esc(tok.n) + '",c[c.length - 1],p,"' + (tok.indent || '') + '");'; 486 | } 487 | 488 | function tripleStache(id, method) { 489 | return 'b += (_.' + method + '("' + esc(id) + '",c,p,0));'; 490 | } 491 | 492 | function variable(id, method) { 493 | return 'b += (_.v(_.' + method + '("' + esc(id) + '",c,p,0)));'; 494 | } 495 | 496 | function text(id) { 497 | return 'b += ' + id + ';'; 498 | } 499 | 500 | return ({ 501 | scan: scan, 502 | 503 | parse: function(tokens, options) { 504 | options = options || {}; 505 | return buildTree(tokens, '', [], options.sectionTags || []); 506 | }, 507 | 508 | cache: {}, 509 | 510 | compile: function(text, options) { 511 | // options 512 | // 513 | // asString: false (default) 514 | // 515 | // sectionTags: [{o: '_foo', c: 'foo'}] 516 | // An array of object with o and c fields that indicate names for custom 517 | // section tags. The example above allows parsing of {{_foo}}{{/foo}}. 518 | // 519 | // delimiters: A string that overrides the default delimiters. 520 | // Example: "<% %>" 521 | // 522 | options = options || {}; 523 | 524 | var t = this.cache[text]; 525 | 526 | if (t) { 527 | return t; 528 | } 529 | 530 | t = generate(this.parse(scan(text, options.delimiters), options), text, options); 531 | return this.cache[text] = t; 532 | } 533 | }); 534 | })(); 535 | 536 | // Export the hogan constructor for Node.js and CommonJS. 537 | if (typeof module !== 'undefined' && module.exports) { 538 | module.exports = Hogan; 539 | module.exports.Template = HoganTemplate; 540 | } else if (typeof define === 'function' && define.amd) { 541 | define(function () { return Hogan; }); 542 | } else if (typeof exports !== 'undefined') { 543 | exports.Hogan = Hogan; 544 | exports.HoganTemplate = HoganTemplate; 545 | } 546 | --------------------------------------------------------------------------------