Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial commit

  • Loading branch information...
commit c54912a7f67a1828c52b044c1e543c1e9eeabca2 0 parents
@mmckegg authored
1  .gitignore
@@ -0,0 +1 @@
+node_modules
460 README.md
@@ -0,0 +1,460 @@
+Realtime Templates
+===
+
+Render views on the server (using standard HTML markup) that the browser can update in realtime when the original data changes.
+
+## Why?
+
+Web pages are increasingly becoming much more than static documents - they display complex, constantly changing information and often allow the user to update that information directly.
+
+Most templating systems were designed for rendering pages on the server then sending this to the browser to display. This is fast, and efficient allowing the web to work the way it always worked. However anything that we want to work in realtime must be written twice - the code that generates it on the server and the code that knows how to update it in the browser. This is time consuming and error prone.
+
+A more recent concept is to do all of the rendering in the browser itself (popular with single page apps). Essentially the server becomes little more than a database. These systems have a number of problems such as search engine accessibility, initial load speed, and bookmarking. In my opinion this is little more than a hack and completely goes against the original design of the web and browsers.
+
+Over time fully client side rendering will improve, but this module attempts to get the best of both worlds right now.
+
+## Work in progress
+
+None of this stuff is up on NPM yet...
+
+## The Views
+
+Views are written in pure HTML markup with a few extra attributes to make things work.
+
+```html
+<!--/views/page.html-->
+<html>
+ <head>
+ <title t:bind='title'>My Blog</title>
+ </head>
+ <body>
+ <h1 t:bind='title'>My Blog</h1>
+ <h2>Page Title</h2>
+ <div>
+ <p>I am the page content</p>
+ </div>
+ </body>
+</html>
+```
+
+In the example above we have a standard html page with one extra attribute `t:bind`.
+
+When this view is rendered, it queries the datasource and replaces any existing content with the found value. The final page that gets to the user has no special t:bind type tags - instead just the final document.
+
+What allows the system to update in realtime is that we send the **full datasource** and **parsed view** (the HTML is parsed into JSON) to the browser along with the rendered page. A little bit of extra meta data is added to the elements so that the system can tell what is what. It's also smart enough to ignore (and leave alone) any extra elements that are added at runtime - e.g. editors / menus / pop-ups.
+
+## Using on the server in Node.js
+
+```js
+var http = require('http')
+
+var realtimeTemplates = require('realtime-templates')
+var jsonContext = require('json-context')
+
+var viewPath = path.join(__dirname, '/views')
+
+// create a renderer - pass in the path containing the HTML views and any options
+var renderer = realtimeTemplates(viewPath, {includeBindingMetadata: true})
+
+http.createServer(function (req, res) {
+ res.writeHead(200, {'Content-Type': 'text/html'});
+
+ // get the data from the database (or in this case, hard coded JSON)
+ var datasource = jsonContext({
+ title: "Matt's blog",
+ element_id: "value",
+ type: 'example'
+ })
+
+ // render the page and return the result
+ renderer.render('page', datasource, function(err, result){
+ res.end(result);
+ })
+
+}).listen(1337, '127.0.0.1');
+```
+
+### realtimeTemplates(viewPath, options)
+
+The easy way to use Realtime Templates. Pass in the path to a directory containing your HTML views and it returns a template **renderer**.
+
+Options:
+ - **includeBindingData** (defaults `false`): Whether or not to include `data-tx` and `data-ti` attributes on the page to allow realtime updating, and also whether to include the datasource and used views in a script tag at the bottom.
+ - **formatters**: Accepts a hash containing named functions representing a custom method of rendering the html from the original value. See Attribute: `t:format`.
+ - **masterName**: Pass in the name of a view to use as the overall master layout for all rendered views. Masters must be saved as `<masterName>.master.html`. See Attribute: `t:content`
+
+### renderer.render(viewName, datasource, callback)
+
+**viewName**: Specify the name of a view. It will be the filename minus the `.html`. e.g. For the file `page.html` the viewName would be `page`.
+
+**datasource**: Pass in a datasource object such as [JSON Context](http://github.com/mmckegg/json-context). The renderer will use this data to put the values into the page.
+
+**callback**: function(err, result) where result is the final rendered page in HTML. Errors might be returned if the viewName specified doesn't exist, or there was a fatal problem rendering the page.
+
+### realtimeTemplates.parseView(rawView)
+
+For custom use - pass in a raw HTML template string and it will be parsed into JSON.
+
+### realtimeTemplates.renderView(view, datasource, options)
+
+Pass in a parsed JSON view and a datasource and the function will return an array of RT **elements**. See Attribute: `t:format`.
+
+### realtimeTemplates.generateHtml(elements)
+
+Pass in an array of RT elements and the function returns HTML.
+
+## Binding to a datasource
+
+This module can be used with any datasource object that responds to `query` and `get` and emits `change` events.
+
+However it was designed to be used with [JSON Context](http://github.com/mmckegg/json-context) - a single object that supports querying (using [JSON Query](https://github.com/mmckegg/json-query)) and contains all data required to render a view/page and providing the client with event stream for syncing with server and data-binding to the dom.
+
+See [JSON Context](http://github.com/mmckegg/json-context) for more information about the datasource interface.
+
+### Attribute: `t:bind`
+
+Anytime the system hits a `t:bind` attribute while rendering the view, it sends the value of this attribute to the datasource `query` function. The return value is inserted as text inside the element.
+
+### Attribute: `t:bind:<attribute-name>`
+
+We can bind arbitrary attributes using the same method by using `t:bind:<attribute-name>`.
+
+For example, if we wanted to bind an element's ID to `element_id` in our datasource:
+```html
+<span t:bind:id='element_id'>content unchanged</span>
+```
+
+Which would output:
+```html
+<span id='value'>content unchanged</span>
+```
+
+### Attribute: `t:if`
+
+The element will only be rendered if the datasource **returns `true`** when queried with the attribute value.
+
+Note that the datasource must provide the exact value `true` - truthy values (such as non-zero number, string, etc) will not be excepted.
+
+### Attribute: `t:unless`
+
+The inverse of `t:if`. The element will only be rendered if the datasource **does not return `true`** when queried with the attribute value.
+
+### Attribute: `t:by` and `t:when`
+
+An extension of the if system. Much like a `switch` or `case` statement. Specify the source query using `t:by` then any sub-elements can use `t:when` to choose what value the `t:by` query must return in order for them to show. Multiple values may be specified by separating with the pipe symbol (e.g. `value1|value2|value3`).
+
+```html
+<div t:by='type'>
+ <div t:when='example'>
+ This div is only rendered if the query "type" returns the value "example".
+ </div>
+ <div t:when='production'>
+ This div is only rendered if the query "type" returns the value "production".
+ </div>
+ <div t:when='trick|treat'>
+ This div is rendered when the query "type" returns the value "trick" or "treat".
+ </div>
+</div>
+```
+
+### Attribute: `t:repeat`
+
+For binding to arrays and creating repeating content. The attribute value is queried and the element is duplicated for every item in the returned array.
+
+For this [JSON Context](http://github.com/mmckegg/json-context) datasource:
+```js
+var datasource = jsonContext({
+ posts: [
+ {id: 1, title: "Post 1", body: "Here is the body content"},
+ {id: 2, title: "Post 2", body: "Here is some more body content"},
+ {id: 3, title: "Post 3", body: "We're done."},
+ ]
+})
+```
+And this template:
+```html
+<div class='post' t:repeat='posts' t:bind:data-id='.id'>
+ <h1 t:bind='.title'>Will replaced with the value of title</h1>
+ <div t:bind='.body'>Will replaced with the value of body</div>
+</div>
+```
+We would get:
+```html
+<div class='post' data-id='1'>
+ <h1>Post 1</h1>
+ <div>Here is the body content</div>
+</div>
+<div class='post' data-id='2'>
+ <h1>Post 2</h1>
+ <div>Here is some more body content</div>
+</div>
+<div class='post' data-id='3'>
+ <h1>Post 3</h1>
+ <div>We're done.</div>
+</div>
+```
+
+### Attribute: `t:view`
+
+Specify a sub-view to render as the content of the element. It must be in the current viewPath in order to be found.
+
+If the element had content specified, it will be overrided with the content of the subview, but if the subview contains an element with the attribute `t:content`, the removed content will be inserted here. This allows creating views that act like wrappers.
+
+### Attribute: `t:content`
+
+This attribute excepts no value and is used on **master views** to denote where to insert the actual view content.
+
+Say we have this master layout:
+```html
+<!--/views/layout.master.html-->
+<html>
+ <head>
+ <title>My Blog</title>
+ </head>
+ <body>
+ <h1>My Blog</h1>
+ <div t:content id='content'></div>
+ </body>
+</html>
+```
+And this view:
+```html
+<h2>Page title</h2>
+<div>I am the page content</div>
+```
+
+We would get:
+```html
+<!--/views/layout.master.html-->
+<html>
+ <head>
+ <title>My Blog</title>
+ </head>
+ <body>
+ <h1>My Blog</h1>
+ <div id='content'> <!--inner view is inserted here-->
+ <h2>Page title</h2>
+ <div>I am the page content</div>
+ </div>
+ </body>
+</html>
+```
+
+### Attribute: `t:format`
+
+This attribute is used to specify a custom renderer to use for rendering the value of `t:bind`. It could be used to apply Markdown or Textile to the original text.
+
+Formatters are functions that except a value parameter and return an array of elements in RT format. The RT format looks something like this:
+```js
+// [tag, attributes, sub-elements]
+['div', {id: 'content'}, [
+ ['h2', {}, [
+ {text: 'Page title'}
+ ]],
+ ['div', {}, [
+ {text: 'I am the page content'}
+ ]]
+]]
+```
+
+Formatters are specified when creating the RT renderer. This example formatter replaces new lines with `<br/>` tags:
+
+```js
+function formatMultiLine(input){
+ var result = []
+
+ input.split('\n').forEach(function(line, i){
+ if (i > 0){
+ result.push(['br', {}, []])
+ }
+ result.push({text: line})
+ })
+
+ return result
+}
+
+var renderer = realtimeTemplates(viewPath, {
+ includeBindingMetadata: true,
+ formatters: {
+ multiline: formatMultiLine //specify the function we created above as a formatter
+ }
+})
+```
+
+## Making it realtime
+
+After the page is loaded in the browser, the system scans for meta tags (`data-tx` and `data-ti`) added by the renderer to figure out what every thing is. It queries the datasource and creates a two way reference between the original object and the element that represents that object.
+
+Then it listens for `change` events on the datasource, using the two way reference to figure out what needs to change, or if it needs to add a new element or remove an existing one.
+
+### Using it with [JSON Context](http://github.com/mmckegg/json-context)
+
+With JSON Context, rather than updating objects directly, we use change streams to push new or changed objects in. This means we have to tell the context how to handle the various changes that could be piped in. It makes sense to have the this map **directly to our database** 1:1. We do this using **matchers**.
+
+We also need a way to identify each object that will need to be changed. Since we made the matchers correspond to our database objects, we can use the unique ID provided by the database.
+
+Say we have the following context:
+```js
+{
+ posts: [
+ {id: 1, type: 'post', title: "Post 1", body: "Here is the body content"},
+ {id: 2, type: 'post', title: "Post 2", body: "Here is some more body content"},
+ {id: 3, type: 'post', title: "Post 3", body: "We're done."},
+ ],
+ comments: {
+ '2': [
+ {id: 1, post_id: 2, type: 'comment', name: 'Anonymous Coward' text: "This is dumb"},
+ {id: 2, post_id: 2, type: 'comment', name: 'Bill Gates', text: "wish i thought of this!"}
+ ]
+ } // We have grouped/indexed the comments by post_id as this is how they will be most commonly accessed.
+}
+```
+
+
+If we wanted to be able to add comments in realtime, we would add the following matcher:
+```js
+{
+ filter:{
+ match: {type: 'comment'}, // we apply the rule if the incoming object has the type 'comment'
+ append: true
+ },
+ item: 'comments[][id={.id}]', // a JSON Query that finds the comment so it can be updated
+ collection: 'comments[{.post_id}]' // a JSON Query that specifies where append new items
+}
+```
+
+When the `includeBindingMetadata` option is enabled, the renderer automatically appends a script tag to the output (with type='text/json' so it won't execute) that contains a full copy of the datasource and the templates used to render it. We can pull that data in and recreate the context in the browser
+
+```js
+// client-side require using browserify
+var jsonContext = require('json-context')
+
+var bindingElement = document.getElementById('realtimeBindingInfo')
+var meta = JSON.parse(bindingElement.innerHTML)
+
+window.context = jsonContext(meta.data, {matchers: meta.matchers})
+```
+
+Now we just need to subscribe to the server's change feed. The server will send us the new object, and the matchers are used to figure out if we care and where to update if we do.
+
+```js
+
+ // client-side require using browserify
+ var shoe = require('shoe')
+ var split = require('split')
+
+ shoe('/changes').pipe(split()).on('data', function(line){
+ // push the changed objects coming down the wire directly into the context
+ window.context.pushChange(JSON.parse(line), {source: 'server'})
+ })
+
+```
+
+Here is the view that we will use:
+```html
+<div t:repeat='posts'>
+ <h2 t:bind='.title'></h2>
+ <div t:format='multiline' t:bind='.body'></div>
+ <div class='comments'>
+ <h3>Comments</h3>
+ <div t:repeat='comments[{.id}]'>
+ <h4 data-bind='.name'></h4>
+ <div t:format='multiline' t:bind='.text'></div>
+ </div>
+ </div>
+</div>
+```
+
+Let's bind it to the datasource
+```js
+// client-side require using browserify
+var realtimeTemplates = require('realtime-templates')
+
+realtimeTemplates.bind(meta.view, window.context)
+```
+
+Now whenever comments are added on the server, they will be updated in realtime in the browser.
+
+The next step is to allow the user to add comments to the page and have these pushed back to the server.
+
+### realtimeTemplates.bind(view, datasource, options)
+
+This must be run in the browser in order for the page to work in realtime.
+
+**view**: Pass in the parsed view that should be included in a JSON script element with the ID `realtimeBindingInfo` as `view`.
+
+**datasource**: The reconstituted datasource object based on the data included with `realtimeBindingInfo`.`data`
+
+Options:
+ - **rootElement** (defaults `document`): A DOM element that corresponds to the root node in the view.
+ - **formatters**: Should be hooked up to the same list of formatters as it's server side counterpart. See Attribute: t:format
+ - **behaviors**: An object containing a list of functions to be run when is extended with `data-behavior`. See Extending with behaviors
+
+It returns an EventEmitter that emits `append`, `beforeRemove` and `remove` events with a single parameter: `node`. These can be used to add animation or other hooks.
+
+```js
+var jsonContext = require('json-context')
+var realtimeTemplates = require('realtime-templates')
+
+var bindingElement = document.getElementById('realtimeBindingInfo')
+var meta = JSON.parse(bindingElement.innerHTML)
+
+window.context = jsonContext(meta.data, {matchers: meta.matchers})
+var binder = realtimeTemplates.bind(meta.view, window.context)
+
+binder.on('append', function(node){
+ animations.slideDown(200, node)
+})
+
+binder.on('beforeRemove', function(node, wait){
+ // wait is a function that can be called to delay (in milliseconds) the actual removal of the element to allow for an animation.
+ animations.slideUp(200, node)
+ wait(200)
+})
+
+```
+
+
+### context.pushChange(object, changeInfo)
+
+**object**: The complete object that has been changed, created, or deleted.
+
+**changeInfo** options:
+ - **source**: either `server` or `user`. If server the change will not be validated and will be applied to the context regardless. If user, the matcher must explicity allow the type of change ('update', 'append' or 'remove').
+
+When a changed object comes down the stream from the server, call `context.pushChange(changedObject, {source: 'server'})` and the change will be merged into the context (as long as there is a corresponding matcher).
+
+If you want to **update** an object in the browser with a form for example, you must first obtain a copy of the object you wish to change. You can use `context.obtain(query)` to do this, or clone an object using `jsonContext.obtain(element.source)`. Once you have this copy, make the desired changes, then push back in using `context.pushChange(changedObject, {source: 'user'})`. The context will check the matcher to ensure the change they have requested is allowed.
+
+To **delete** an object, obtain in the same way as changing, but add the key `_deleted` with the value `true`.
+
+```js
+var object = window.context.obtain(['comments[][id=?]', 1]) // get a copy of the object
+object._deleted = true
+window.context.pushChange(object, {source: 'user'})
+```
+
+If you want to **append** a new object, just push it directly. As long as it has attributes corresponding to the matcher, everything should just work.
+
+## Extending with behaviors
+
+`data-behaviors` attribute
+
+## TODO
+
+ - Testing the browser stuff - maybe with testling or something?
+ - Currently preserving elements added at runtime, but would be nice if could also preserve attributes - e.g. additional styles, classes etc.
+
+## Compatibility
+
+The server side will run on Node.js.
+
+Mostly cross-browser when using [shimify](https://github.com/substack/node-shimify) and [browserify](https://github.com/substack/node-browserify).
+
+### Ruby (and other platforms)
+
+With some clever hacking, RT can be run inside a Ruby project using something like [therubyracer](https://github.com/cowboyd/therubyracer).
+
+I am currently running Realtime Templates inside a Ruby on Rails project in production. I used Browserify to generate a package which could be run directly on therubyracer with no dependencies on Node. I do all of the view loading and parsing in the build step and is pulled into the browserify package precompiled. The data is generated in the ruby code and passed off to a function in therubyracer that renders that data into HTML.
+
+It works remarkably well. My plan is eventually to be running 100% on Node.js, but this has helped to get a little bit of Node niceness into my Rails without huge amounts of infrastructure restructuring.
468 browser/bind_dom.js
@@ -0,0 +1,468 @@
+var EventEmitter = require('events').EventEmitter
+ , generateNodes = require('./generate_nodes')
+ , renderTemplate = require('../shared/render_template')
+ , refreshDom = require('./refresh_dom')
+ , walkDom = require('./walk_dom')
+
+module.exports = function(view, datasource, options){
+ // options: rootElement, formatters, behaviors
+
+ var binder = new EventEmitter()
+ binder.datasource = datasource
+ binder.rootElement = options && options.rootElement || document
+ binder.view = view
+ binder.formatters = options.formatters
+ binder.behaviors = options.behaviors || {}
+
+ datasource.on('change', function(object, changeInfo){
+
+ if (changeInfo.action === 'append'){
+ refreshNodes(changeInfo.collection.$elements)
+ append({collection: changeInfo.collection, item: object})
+ checkNodePosition(object, changeInfo)
+ }
+
+ if (changeInfo.action === 'update'){
+ refreshNodes(changeInfo.collection.$elements)
+
+ checkNodeCollection(object, changeInfo)
+ checkNodePosition(object, changeInfo)
+
+ refreshNodes(findElementsInObject(object), {ignoreAppendFor: changeInfo.addedItems})
+
+ // add and remove sub template items
+ changeInfo.removedItems && changeInfo.removedItems.forEach(removeSourceElements)
+ changeInfo.addedItems && changeInfo.addedItems.forEach(append)
+ }
+
+ if (changeInfo.action === 'remove'){
+ removeSourceElements({collection: changeInfo.collection, item: object})
+ }
+ })
+
+ setUpBindings(binder)
+
+
+ function removeSourceElements(x){
+ if (x.item.$elements){
+ var itemsToRemove = x.item.$elements.filter(function(element){
+ return element.source === x.item
+ })
+ if (itemsToRemove.length > 0){
+ refreshNodes(x.collection.$elements)
+ itemsToRemove.forEach(remove)
+ }
+ }
+ }
+
+ function checkNodeCollection(object, changeInfo){
+ if (changeInfo.originalCollection && object.$elements){
+
+ var unhandledPlaceholders = (changeInfo.collection.$placeholderElements || []).concat()
+
+ object.$elements.forEach(function(element){
+ if (element.source === object && element.collection === changeInfo.originalCollection){
+
+ unbind(element)
+
+ if (!changeInfo.originalCollection.$placeholderElements.some(function(placeholder){
+
+ // remove nodes in wrong location
+ if (element.template === placeholder.template && element.parentNode === placeholder.parentNode){
+ remove(element)
+ console.log("Removed element", placeholder, element)
+ return true
+ }
+
+ })) {
+
+ // rebind nodes that have already been moved to correct location
+ bindNode(element, object, changeInfo.collection, element.template, element.view, binder)
+
+ // remove the elements placeholder from unhandled
+ unhandledPlaceholders.some(function(placeholder, i){
+ if (element.template === placeholder.template && element.parentNode === placeholder.parentNode){
+ unhandledPlaceholders.splice(i, 1)
+ console.log("Object already existing in destination collection. Moved manually", placeholder, element)
+ return true
+ }
+ })
+ }
+ }
+ })
+
+ unhandledPlaceholders.forEach(function(placeholder, pi){
+ // append elements to correct locations
+ appendObjectToPlaceholder(object, changeInfo.collection, placeholder)
+ console.log("Generated new element", placeholder)
+ })
+
+ }
+ }
+
+ function checkNodePosition(object, changeInfo){
+ if (changeInfo.before && object.$elements){
+
+ // check to see if should insert at end
+ var beforeElementList = (changeInfo.before === 'end') ? changeInfo.collection.$placeholderElements : changeInfo.before.$elements
+
+ if (beforeElementList){
+ object.$elements.forEach(function(element){
+ // find the correct insert point for the current element
+ beforeElementList.some(function(beforeElement){
+ if (element.template === beforeElement.template && element.parentNode === beforeElement.parentNode){
+ if (element.nextSibling !== beforeElement){
+ beforeElement.parentNode.insertBefore(element, beforeElement)
+ return true
+ }
+ }
+ })
+
+ })
+ }
+
+ }
+ }
+
+ function remove(element){
+ // hooks for animation
+ var waiting = false
+ var timer = null
+ function doIt(){
+ element.parentNode.removeChild(element)
+ binder.emit('remove', element)
+ }
+ function wait(seconds){
+ clearTimeout(timer)
+ waiting = true
+ timer = setTimeout(doIt, seconds)
+ }
+ binder.emit('beforeRemove', element, wait)
+ if (!waiting) doIt()
+ }
+
+ function appendObjectToPlaceholder(object, collection, placeholder){
+ var i = collection.indexOf(object)
+
+ var elements = renderTemplate(placeholder.template, object, {
+ datasource: binder.datasource,
+ formatters: binder.formatters,
+ view: binder.view,
+ ti: (placeholder.viewName || '') + ':' + placeholder.template.id + ':' + i,
+ includeBindingMetadata: true
+ })
+
+ function behaviorHandler(n){
+ bindBehavior(n, object, binder)
+ }
+
+ var appendedTemplateNodes = []
+
+ generateNodes(elements, {behaviorHandler: behaviorHandler, templateHandler: function(entity, element){
+ bindTemplatePlaceholder(entity, element, binder)
+ appendedTemplateNodes.push(element)
+ }}).forEach(function(node){
+ appendNode(node, placeholder)
+ bindNode(node, object, collection, placeholder.template, placeholder.view, binder)
+ })
+
+ appendedTemplateNodes.forEach(function(element){
+ element.source.forEach(function(item){
+ append({collection: element.source, item: item})
+ })
+ })
+ }
+
+ function append(toAppend){
+ var collection = toAppend.collection
+ var object = toAppend.item
+
+ if (collection.$placeholderElements){
+ collection.$placeholderElements.forEach(function(placeholder){
+ appendObjectToPlaceholder(object, collection, placeholder)
+ })
+ }
+ }
+
+ function appendNode(node, placeholder){
+ if (placeholder.parentNode){
+ placeholder.parentNode.insertBefore(node, placeholder)
+ binder.emit('append', node)
+ }
+ }
+
+ function refreshNodes(nodes, options){
+ // options: ignoreAppendFor
+ options = options || {}
+ var appendedTemplateNodes = []
+
+ if (nodes){
+ nodes.forEach(function(node){
+ if (node.template){
+
+ var object = node.source
+ if (object.$isProxy){
+ // handle element sources that are strings/numbers rather than objects
+ object = object.value
+ }
+
+ var newElement = renderTemplate(node.template, object, {
+ datasource: binder.datasource,
+ formatters: binder.formatters,
+ view: binder.view,
+ ti: node.getAttribute('data-ti'),
+ includeBindingMetadata: true
+ })[0]
+
+
+ refreshDom(node, newElement, {
+ behaviorHandler: function(n){
+ bindBehavior(n, object, binder)
+ },
+ templateHandler: function(entity, element){
+ bindTemplatePlaceholder(entity, element, binder)
+ appendedTemplateNodes.push(element)
+ },
+ removeHandler: function(element){
+ unbind(element, binder)
+ }
+ })
+ }
+ })
+ }
+
+ appendedTemplateNodes.forEach(function(element){
+ element.source.forEach(function(item){
+ if (!options.ignoreAppendFor || !options.ignoreAppendFor.some(function(x){
+ return x.collection === element.source && x.item === item
+ })){
+ append({collection: element.source, item: item})
+ }
+ })
+ })
+ }
+ return binder
+}
+
+
+
+function findElementsInObject(object){
+ var elements = []
+
+ function findElements(obj){
+ if (obj instanceof Object){
+ if (obj.$elements){
+ obj.$elements.forEach(function(e){
+ addSet(elements, e)
+ })
+ }
+ if (Array.isArray(obj)){
+ obj.forEach(findElements)
+ if (obj.$proxy){
+ obj.$proxy.forEach(findElements)
+ }
+ } else {
+ Object.keys(obj).forEach(function(key){
+ if (key.charAt(0) !== '$'){
+ findElements(obj[key])
+ }
+ })
+ }
+ }
+ }
+
+ findElements(object)
+ return elements
+}
+
+function bindTemplatePlaceholder(entity, element, binder){
+ var currentView = binder.view.views[entity.template[0]] || binder.view
+ var template = currentView.templates[entity.template[1]]
+ var source = binder.datasource.get(template.query, entity._context, {force: []})
+
+ addBindingMetadata({
+ element: element,
+ item: source,
+ template: template,
+ view: currentView,
+ viewName: entity.template[0],
+ isSource: true
+ })
+}
+
+function setUpBindings(binder){
+
+ // bind root node to context
+ addBindingMetadata({
+ element: binder.rootElement,
+ item: binder.datasource.data,
+ template: binder.view,
+ isSource: true
+ })
+
+ walkDom(binder.rootElement, function(node, state){
+ var currentView = state.get('state')
+
+ if (isTemplate(node)){
+ // find the template and view
+ var ti = node.getAttribute('data-ti').split(':')
+ var currentView = ti[0] && binder.view.views[ti[0]] || binder.view
+ var currentTemplate = currentView
+ if (ti[1]){
+ currentTemplate = currentView.templates[ti[1]]
+ }
+
+ // get the source, and force it if it doesn't exit
+ var collection = binder.datasource.query(currentTemplate.query, state.get('source'), {force: []}).value
+ var currentSource = collection[parseInt(ti[2], 10)]
+
+ if (currentSource){
+ bindNode(node, currentSource, collection, currentTemplate, currentView, binder)
+ }
+
+ state.set('source', currentSource)
+
+ } else if (isPlaceholder(node)){
+ // get the template then use query to find the source using the current source as context
+ var data = node.data.split(':')
+
+ var view = data[0] && binder.view.views[data[0]] || binder.view
+ var template = data[1] && view.templates[data[1]] || view
+
+ var source = binder.datasource.query(template.query, state.get('source'), {force: []}).value
+
+ addBindingMetadata({
+ element: node,
+ item: source,
+ template: template,
+ view: view,
+ viewName: data[0],
+ isSource: true
+ })
+ }
+
+ // if has behavior, initialize
+ if (node.nodeType === 1){
+ bindBehavior(node, state.get('source'), binder)
+ }
+
+ })
+
+}
+
+function bindBehavior(node, source, binder){
+ var behaviorName = node.getAttribute('data-behavior')
+ if (behaviorName && binder.behaviors[behaviorName]){
+ binder.behaviors[behaviorName](node, {object: source, datasource: binder.datasource})
+ }
+}
+
+function unbind(node, binder){
+ if (node.nodeType === 1){
+ if (node.source && node.source.$elements){
+ var index = node.source.$elements.indexOf(node)
+ if (~index){
+ node.source.$elements.splice(index, 1)
+ }
+ }
+ if (node.collection && node.collection.$elements){
+ var index = node.collection.$elements.indexOf(node)
+ if (~index){
+ node.collection.$elements.splice(index, 1)
+ }
+ }
+ for (var i=0;i<node.childNodes.length;i++){
+ unbind(node.childNodes[i], binder)
+ }
+ } else if (node.nodeType === 8){
+ if (node.source && node.source.$placeholderElements){
+ var index = node.source.$placeholderElements.indexOf(node)
+ if (~index){
+ node.source.$placeholderElements.splice(index, 1)
+ }
+ }
+ }
+}
+
+function bindNode(node, source, collection, template, view, binder){
+
+ addBindingMetadata({
+ element: node,
+ item: source,
+ collection: collection,
+ template: template,
+ view: view,
+ isSource: true //two way binding
+ })
+
+ template.bindings.forEach(function(binding){
+ // only bind if is not a local query
+ if (binding.lastIndexOf('.') < 0){
+ var result = binder.datasource.query(binding, source)
+ result.references.forEach(function(reference){
+ addBindingMetadata({
+ element: node,
+ item: reference
+ })
+ })
+
+ }
+ })
+}
+
+function isTemplate(node){
+ return node.nodeType === 1 && node.getAttribute('data-ti')
+}
+function isPlaceholder(node){
+ /// this should probably be a more rigorous test
+ return node.nodeType === 8
+}
+function addBindingMetadata(options){
+ // options: element, template, item, isSource
+
+ var item = options.item || {}
+ , element = options.element
+ , template = options.template
+ , view = options.view
+ , collection = options.collection
+
+ if (!(item instanceof Object)){
+ // for handling element sources that are strings/numbers rather than objects
+ item = getItemProxy(item, collection)
+ }
+
+ if (isPlaceholder(options.element)){
+ // is a placeholder element
+ if (!item.$placeholderElements) item.$placeholderElements = []
+ addSet(item.$placeholderElements, element)
+ } else {
+ if (!item.$elements) item.$elements = []
+ addSet(item.$elements, element)
+ }
+
+ if (options.isSource){
+ if (Array.isArray(collection)){
+ element.collection = collection
+ }
+ element.viewName = options.viewName
+ element.source = item
+ element.template = template
+ element.view = view
+ }
+}
+
+function getItemProxy(item, collection){
+ var index = collection.indexOf(item)
+ if (!collection.$proxy) collection.$proxy = []
+ if (!collection.$proxy[index]) collection.$proxy[index] = {value: item, $isProxy: true}
+ return collection.$proxy[index]
+}
+
+function addSet(a, item){
+ if (a.indexOf(item) == -1){
+ a.push(item)
+ }
+}
+function addSetAll(a, items){
+ items.forEach(addSet.bind(a))
+}
85 browser/generate_nodes.js
@@ -0,0 +1,85 @@
+var updateAttributes = require('./update_attributes')
+
+var self = module.exports = function(elements, options){
+ // options: templateHandler, behaviorHandler
+
+ var result = []
+ elements.forEach(function(element){
+ // recursively render all elements
+ var node = self.generate(element, options)
+ if (node){
+ if (Array.isArray(node)){
+ node.forEach(function(n){
+ result.push(n)
+ })
+ } else {
+ result.push(node)
+ }
+ }
+ })
+ return result
+}
+
+self.generate = function(element, options){
+
+ if (Array.isArray(element)){
+
+ // standard element
+ var newNode = document.createElement(element[0])
+
+ updateAttributes(newNode, element[1])
+
+ if (element[2]){
+ element[2].forEach(function(e){
+ // recursively render all elements
+ var subNodes = self.generate(e, options)
+ if (Array.isArray(subNodes)){
+ subNodes.forEach(function(node){
+ newNode.appendChild(node)
+ })
+ } else if (subNodes){
+ newNode.appendChild(subNodes)
+ }
+ })
+ }
+
+ if (options.behaviorHandler && element[1]['data-behavior']){
+ options.behaviorHandler(newNode)
+ }
+
+ return newNode
+
+
+ } else if (element instanceof Object){
+ // entity: e.g. Template
+
+ if (element.text){
+ return document.createTextNode(element.text.toString())
+ }
+ if (element.comment){
+ return document.createComment(element.comment.toString())
+ } else {
+
+
+ if (element.template && Array.isArray(element.template)){
+ var placeholder = self.generatePlaceholder(element.template.join(':'))
+ if (options && options.templateHandler){
+ options.templateHandler(element, placeholder)
+ }
+ return placeholder
+ }
+
+ }
+
+
+ } else {
+ // text node
+ if (element != null && element.toString){
+ return document.createTextNode(element.toString())
+ }
+ }
+}
+
+self.generatePlaceholder = function(templateId){
+ return document.createComment(templateId)
+}
272 browser/refresh_dom.js
@@ -0,0 +1,272 @@
+var generateNodes = require('./generate_nodes')
+ , updateAttributes = require('./update_attributes')
+
+module.exports = function(rootNode, newElement, options){
+ // options: templateHandler, behaviorHandler
+ //var walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT, null, null)
+ var nodeStack = []
+ var currentNode = rootNode
+
+ function removeNode(node){
+ if (options && options.removeHandler){
+ options.removeHandler(node)
+ }
+ node.parentNode.removeChild(node)
+ }
+
+ function updateElement(element){
+ var movement = moveToElementOrGenerate(element)
+ var elementType = getElementType(element)
+
+ if (!movement){
+ return false
+ }
+
+ // remove unused content
+ movement.skippedNodes.forEach(function(node){
+ removeNode(node)
+ })
+
+ if (elementType === 'element'){
+ // if elements was just generated we can skip this step
+ if (!movement.generated){
+ updateAttributes(currentNode, element[1])
+ stepIn()
+ element[2].forEach(function(e){
+ updateElement(e)
+ stepForward()
+ })
+ stepOut()
+ }
+
+ } else if (elementType === 'text'){
+ if (!movement.generated){
+ if (currentNode.data !== element.text){
+ currentNode.data = element.text
+ }
+ }
+ } else if (elementType === 'template'){
+ if (movement.generated){
+ console.log("TODO: Need to render templates here", movement)
+ //TODO: render these templates now (since we don't have them)
+ }
+ }
+
+ }
+
+ function stepIn(){
+ nodeStack.push(currentNode)
+ currentNode = currentNode.firstChild
+ }
+
+ function stepOut(){
+ // remove remaining nodes
+ if (currentNode){
+ while (currentNode.nextSibling){
+ removeNode(currentNode.nextSibling)
+ }
+ removeNode(currentNode)
+ }
+
+ currentNode = nodeStack.pop()
+ }
+
+ function stepForward(){
+ if (currentNode){
+ currentNode = currentNode.nextSibling
+ }
+ }
+
+ function moveToElementOrGenerate(element){
+ var skipped = []
+
+ var pos = currentNode
+
+ var searchingFor = parseTx(Array.isArray(element) ? element[1]['data-tx'] : element._tx)
+ var searchingForType = getElementType(element)
+
+ if (searchingForType === 'template' && !Array.isArray(element.template)){
+ debugger
+ }
+
+ var searching = true
+ while (searching && pos){
+ if (pos === rootNode || (pos.getAttribute && pos.getAttribute('data-tx') && !pos.getAttribute('data-ti'))){
+
+ // must be a standard node created from a view element
+ var comparison = txComp(searchingFor, pos.getAttribute('data-tx'))
+ if (comparison >= 0){
+ searching = false
+ } else {
+ // this is not the node we are looking for
+ skipped.push(pos)
+ pos = pos.nextSibling
+ }
+
+ } else {
+
+ if (pos.nodeType == 3){
+
+ // text node
+ if (searchingForType === 'text' && isNextElementTooFar(pos, searchingFor)){
+ searching = false
+ } else {
+ skipped.push(pos)
+ pos = pos.nextSibling
+ }
+
+ } else if(pos.nodeType === 1 && pos.getAttribute('data-ti')){
+
+ // template node - either skip it or move to end if matching
+ var ti = pos.getAttribute('data-ti').split(':')
+ if (searchingForType === 'template' && ti[0] == element.template[0] && ti[1] == element.template[1]){
+
+ pos = findPlaceholder(pos, element.template.join(':'))
+ searching = false
+ } else {
+ skipped.push(pos)
+ pos = pos.nextSibling
+ }
+
+ } else if (pos.nodeType === 8) {
+
+ if (searchingForType === 'template' && element.template.join(':') == pos.data){
+ searching = false
+ } else {
+ skipped.push(pos)
+ pos = pos.nextSibling
+ }
+
+ } else {
+ // random node we don't understand (must have been added client side) - just ignore it and pass over
+ pos = pos.nextSibling
+ }
+
+ }
+ }
+
+ if (searching){
+
+ // node wasn't found .. append to end
+ var newNode = generateNodes.generate(element, options)
+ var parentNode = nodeStack[nodeStack.length-1]
+ if (newNode && parentNode){
+ parentNode.appendChild(newNode)
+ currentNode = newNode
+ return {node: currentNode, generated: true, skippedNodes: skipped}
+ } else {
+ return false
+ }
+
+ } else {
+ if (!pos.getAttribute || !pos.getAttribute('data-tx') || !txComp(searchingFor, pos.getAttribute('data-tx'))){
+
+ // node was found ... we're done!
+ currentNode = pos
+ return {node: currentNode, skippedNodes: skipped}
+
+ } else {
+
+ // node was missing, insert at this point
+ var newNode = generateNodes.generate(element, options)
+ var parentNode = nodeStack[nodeStack.length-1]
+ if (newNode && parentNode){
+ parentNode.insertBefore(newNode, pos)
+ currentNode = newNode
+ return {node: newNode, generated: true, skippedNodes: skipped}
+ } else {
+ return false
+ }
+
+ }
+ }
+
+ }
+
+ updateElement(newElement)
+
+}
+
+function isNextElementTooFar(current, tx){
+ var nextElement = getNextStandardElement(current)
+ return nextElement == null || txComp(tx, nextElement.getAttribute('data-tx')) === 1
+}
+
+function findPlaceholder(startingPoint, value){
+ var pos = startingPoint
+ var searching = true
+ while (pos && searching){
+ if (pos.nodeType === 8 && pos.data == value){
+ return pos
+ } else {
+ pos = pos.nextSibling
+ }
+ }
+ return null
+}
+
+function getNextStandardElement(node){
+ var pos = node.nextSibling
+ while (pos && (!pos.getAttribute || pos.getAttribute('data-ti') || !pos.getAttribute('data-tx'))){
+ pos = pos.nextSibling
+ }
+ return pos
+}
+
+function txComp(txA, txB){
+ txA = parseTx(txA)
+ txB = parseTx(txB)
+ for (var i=0;i<txA.length;i++){
+ if (txB[i] == null || txA[i] > txB[i]){
+ return -1
+ } else if (txA[i] < txB[i]) {
+ return 1
+ }
+ }
+ if (txB.length > txA.length){
+ return 1
+ } else {
+ return 0
+ }
+}
+
+function parseTx(tx){
+ if (tx && typeof tx === 'string'){
+ return tx.split('-').map(function(s){return parseInt(s,10)})
+ } if (typeof tx === 'number'){
+ return [tx]
+ } else if (Array.isArray(tx)){
+ return tx
+ } else {
+ return [-1]
+ }
+}
+
+function getNextTx(node){
+ var pos = getNextStandardElement(node)
+ if (pos){
+ return parseInt(c.getAttribute('data-tx'))
+ } else {
+ return null
+ }
+}
+
+function getElementType(element){
+ if (Array.isArray(element)){
+ return 'element'
+ } else {
+ if (element.text){
+ return 'text'
+ } else if (element.template){
+ return 'template'
+ } else if (element.comment){
+ return 'comment'
+ }
+ }
+}
+
+function clearChildren(element){
+ while (element.firstChild) {
+ element.removeChild(element.firstChild);
+ }
+}
72 browser/update_attributes.js
@@ -0,0 +1,72 @@
+var attributeProperties = {
+ "tabindex": "tabIndex",
+ "readonly": "readOnly",
+ "for": "htmlFor",
+ "class": "className",
+ "maxlength": "maxLength",
+ "cellspacing": "cellSpacing",
+ "cellpadding": "cellPadding",
+ "rowspan": "rowSpan",
+ "colspan": "colSpan",
+ "usemap": "useMap",
+ "frameborder": "frameBorder",
+ "contenteditable": "contentEditable"
+}
+
+module.exports = function(node, attributes){
+ var removeAttributes = []
+ for (var i = 0; i < node.attributes.length; i++) {
+ var attribute = node.attributes[i];
+ if (attribute.specified) {
+ if (attributes[attribute.name] == null || attributes[attribute.name] === ''){
+ removeAttributes.push(attribute.name)
+ }
+ }
+ }
+ Object.keys(attributes).forEach(function(k){
+ if (k.charAt(0) !== '_'){
+ var v = attributes[k]
+ if (getAttribute(node, k) != v){
+ setAttribute(node, k, v)
+ }
+ }
+ })
+ removeAttributes.forEach(function(k){
+ removeAttribute(node, k)
+ })
+}
+
+function getAttribute(element, key){
+ var directAttribute = attributeProperties[key.toLowerCase()]
+ if (directAttribute){
+ return element[directAttribute]
+ } else {
+ element.getAttribute(key)
+ }
+}
+
+function setAttribute(element, key, value){
+ if (key === 'style'){
+ element.style.cssText = value
+ } else {
+ var directAttribute = attributeProperties[key.toLowerCase()]
+ if (directAttribute){
+ element[directAttribute] = value
+ } else {
+ element.setAttribute(key, value)
+ }
+ }
+}
+
+function removeAttribute(element, key){
+ if (key === 'style'){
+ element.style.cssText = ''
+ } else {
+ var directAttribute = attributeProperties[key.toLowerCase()]
+ if (directAttribute){
+ element[directAttribute] = ''
+ } else {
+ element.removeAttribute(key)
+ }
+ }
+}
60 browser/walk_dom.js
@@ -0,0 +1,60 @@
+module.exports = function(root, iterator){
+ var stateStack = createStateStack()
+ var currentNode = root.firstChild
+ while (currentNode){
+
+ stateStack.setNode(currentNode)
+ iterator(currentNode, stateStack)
+
+ if (currentNode.firstChild){
+ // walk children
+ currentNode = currentNode.firstChild
+ } else {
+ // check if nextSibling - if not walk up parents
+ while (currentNode && !currentNode.nextSibling){
+ if (currentNode !== root) {
+ currentNode = currentNode.parentNode
+ stateStack.popNode(currentNode)
+ } else {
+ currentNode = null
+ }
+ }
+ currentNode = currentNode && currentNode.nextSibling
+ }
+
+ }
+}
+
+function createStateStack(rootNode){
+ var currentNode = rootNode || null
+
+ var stateStacks = {}
+ var state = {}
+
+ var stack = {
+ set: function(key, value){
+ if (state[key]){
+ if (!stateStacks[key]){
+ stateStacks[key] = []
+ }
+ stateStacks[key].push(state[key])
+ }
+ state[key] = {node: currentNode, value: value}
+ },
+ get: function(key){
+ return state[key] && state[key].value || null
+ },
+ setNode: function(node){
+ currentNode = node
+ },
+ popNode: function(node){
+ Object.keys(state).forEach(function(key){
+ var value = state[key]
+ if (value && value.node === node){
+ state[key] = stateStacks[key] && stateStacks[key].pop() || null
+ }
+ })
+ }
+ }
+ return stack
+}
0  formatters/multiline_formatter.js
No changes.
24 package.json
@@ -0,0 +1,24 @@
+{
+ "author": "Matt McKegg <matt@wetsand.co.nz> (http://twitter.com/MattMcKegg)",
+ "name": "realtime-templates",
+ "description": "...",
+ "version": "0.0.0",
+ "homepage": "http://github.com/mmckegg/realtime-templates",
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/mmckegg/realtime-templates.git"
+ },
+ "main": "realtime-templates.node",
+ "browserify": "realtime-templates.browser",
+ "scripts": {
+ "test": "tap test/*.js"
+ },
+ "dependencies": {
+ "sax": "*"
+ },
+ "devDependencies": {},
+ "optionalDependencies": {},
+ "engines": {
+ "node": "*"
+ }
+}
1  realtime-templates.browser.js
@@ -0,0 +1 @@
+exports.bind = require('./browser/bind_dom')
127 realtime-templates.node.js
@@ -0,0 +1,127 @@
+var fs = require('fs')
+ , path = require('path')
+ , parseView = require('./shared/parse_view')
+ , renderView = require('./shared/render_view')
+ , generateHtml = require('./shared/generate_html')
+
+module.exports = function(viewRoot, options){
+ // options: formatters, includeBindingMetadata, masterName
+
+ options = options || {}
+ var masterName = options.masterName
+
+ var renderer = {}
+
+ var viewCache = {}
+
+ renderer.render = function(viewName, datasource, cb){
+ var startTime = process.hrtime()
+
+ if (viewCache[viewName]){
+ // read from cache
+ var elements = renderView(viewCache[viewName], datasource, options)
+ cb(null, generateHtml(elements), {time: process.hrtime(startTime)})
+ } else {
+ loadView(viewName, function(err, view){ if(err)return cb&&cb(err);
+ assignMasterAndSubViews(view, function(err){ if(err)return cb&&cb(err);
+ // cache view if enabled
+ if (options.useCache){
+ viewCache[viewName] = view
+ }
+ var elements = renderView(view, datasource, options)
+ cb(null, generateHtml(elements), {time: process.hrtime(startTime)})
+ })
+ })
+ }
+ }
+
+ function viewPath(view){
+ return path.join(viewRoot, view + '.html')
+ }
+
+
+ function loadView(name, cb){
+ fs.readFile(viewPath(name), 'utf8', function(err, data){ if (err) return cb&&cb(err);
+ cb(null, parseView(data))
+ })
+ }
+
+ function assignMasterAndSubViews(view, cb){
+ //TODO: wrap with master
+ if (masterName){
+ view.referencedViews.push(masterName + '.master')
+ // wrap the view elements in a master placeholder
+ var placeholder = ['placeholder', {_view: {name: masterName + '.master'}}, view.elements]
+ view.elements = [placeholder]
+ }
+ view.views = {}
+ resolveSubViews(view, function(err){
+ cb(err, view)
+ })
+ }
+
+ function resolveSubViews(view, viewList, cb){
+ if (typeof(viewList) === 'function'){
+ cb = viewList
+ viewList = null
+ }
+ viewList = viewList || view.views || {}
+ view.referencedViews && asyncEach(view.referencedViews, function(item, next){
+ if (viewList[item]){
+ next()
+ } else {
+ loadView(item, function(err, subView){
+ if (err) return next(err);
+ subView.name = item
+ viewList[item] = subView
+ resolveSubViews(subView, viewList, next)
+ })
+ }
+ }, function(err){
+ if (err) return cb&&cb(err);
+ cb()
+ })
+ }
+
+ function getMaster(cb){
+ fs.readFile(masterPath, 'utf8', function(err, data){
+ if (!err){
+ cb(null, data)
+ } else { cb&&cb(err) }
+ })
+ }
+
+ return renderer
+}
+
+// shortcuts to sub modules
+module.exports.renderView = renderView
+module.exports.parseView = parseView
+module.exports.generateHtml = generateHtml
+module.exports.parseView = require('./shared/render_template')
+
+
+
+function asyncEach(collection, iterator, callback){
+ var id = -1
+ , ended = false
+ function end(err){
+ ended = true
+ if (!err){
+ callback()
+ } else {callback(err)}
+ }
+ function next(err, endNow){
+ if (!ended){
+ if (!err){
+ id += 1
+ if (id < collection.length && !endNow){
+ iterator(collection[id], next)
+ } else {
+ end()
+ }
+ } else {end(err)}
+ }
+ }
+ next()
+}
110 shared/check_filter.js
@@ -0,0 +1,110 @@
+//TODO: add $present
+//TODO: add $string
+//TODO: add $number
+//TODO: add $boolean
+//TODO: add $match = 'regexp'
+//TODO: add $max, $min
+//TODO: add $blank
+//TODO: add $null
+//TODO: add $undefined
+
+
+//TODO: Support multiple conditions (conditions handler maybe?)
+
+module.exports = function(source, filter, options){
+ // options: match (source, filter, any)
+ // if filter, every filter permission must be satisfied (i.e. required fields)
+ // if source, every source key must be specified in filter
+ // if any, the keys don't matter, but if there is a match, they must pass
+
+ options = options || {}
+ options.match = options.match || 'filter'
+
+ if (filter && filter.$present && source){
+ return true
+ } else if (filter === null){
+ return true
+ } else if (filter == null/*undefined test*/){
+ return 'undefined'
+ } else if (filter.$any){
+ return true
+ }
+
+ if (source instanceof Object){
+
+ if (filter instanceof Object){
+
+ if (filter.$any){
+ return true
+ } else if (Array.isArray(source)) {
+
+ // source is an array
+
+ if (Array.isArray(filter.$contains)){
+
+ return filter.$contains.every(function(value){
+ return (~source.indexOf(value))
+ })
+
+ } else if (Array.isArray(filter.$excludes)){
+
+ return filter.$excludes.every(function(value){
+ return (!~source.indexOf(value))
+ })
+
+ } else if (Array.isArray(filter)) {
+
+ // both source and filter are arrays, so ensure they match key by key
+ return matchKeys(source, filter, options) && (filter.length == source.length)
+
+ } else {
+
+ // source is array but filter is a hash, so ensure that keys that do exist match filter
+ return matchKeys(source, filter, options)
+
+ }
+
+ } else {
+ // both source and filter are standard hashes so match key by key
+ return matchKeys(source, filter, options)
+ }
+ }
+
+
+ } else {
+ if (Array.isArray(filter.$only)) {
+ return !!~filter.$only.indexOf(source)
+ } else if (Array.isArray(filter.$not)){
+ return !~filter.$not.indexOf(source)
+ } else {
+ return source === filter
+ }
+ }
+
+}
+
+function matchKeys(source, filter, options){
+
+ if (options.match === 'filter'){
+ return Object.keys(filter).filter(isNotMeta).every(function(key){
+ return module.exports(source[key], filter[key])
+ })
+ } else if (options.match === 'source'){
+ return Object.keys(source).filter(isNotMeta).every(function(key){
+ var res = module.exports(source[key], filter[key])
+ if (filter.$optional && ~filter.$optional.indexOf(key)){
+ return res
+ } else if (res !== 'undefined'){
+ return res
+ }
+ })
+ } else {
+ return Object.keys(source).filter(isNotMeta).every(function(key){
+ return module.exports(source[key], filter[key])
+ })
+ }
+}
+
+function isNotMeta(key){
+ return (key.charAt(0) !== '$')
+}
95 shared/generate_html.js
@@ -0,0 +1,95 @@
+var selfClosing = ['meta'
+ , 'img'
+ , 'link'
+ , 'input'
+ , 'area'
+ , 'base'
+ , 'col'
+ , 'br'
+ , 'hr']
+
+
+module.exports = function(elements, entityHandler){
+ var result = ""
+ elements.forEach(function(element){
+ result += module.exports.generateElement(element, entityHandler)
+ })
+
+ return result
+}
+
+module.exports.generateElement = function(element, entityHandler){
+ var result = ""
+
+ if (Array.isArray(element)){
+ // standard element
+ if (isSelfClosing(element[0])){
+ result += openTag(element[0], element[1], true)
+ } else {
+ result += openTag(element[0], element[1])
+ if (element[2]){
+ element[2].forEach(function(e){
+ result += module.exports.generateElement(e, entityHandler) // recursively render all elements
+ })
+ }
+ result += closeTag(element[0])
+ }
+
+ } else if (element instanceof Object){
+
+ // entity e.g. template
+ if (element.text){
+ result += escapeHTML(element.text.toString())
+ } if (element.comment) {
+ result += '<!--' + escapeHTML(element.comment.toString()) + '-->'
+ } else if (entityHandler){
+ var x = entityHandler(element)
+ if (x){
+ result += x
+ }
+ }
+ if (element.template){
+ if (Array.isArray(element.template)){
+ result += '<!--' + escapeHTML(element.template.join(':')) + '-->'
+ }
+ }
+ } else {
+ // text node
+ if (element != null && element.toString){
+ result += escapeHTML(element.toString())
+ }
+
+ }
+
+ return result
+}
+
+function isSelfClosing(name){
+ return selfClosing.indexOf(name) >= 0
+}
+
+function escapeAttribute(attribute){
+ return attribute || attribute === 0 ? attribute.toString().replace(/"/g, '&quot;') : '';
+}
+function escapeHTML(s) {
+ return s ? s.toString().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
+}
+
+function openTag(name, attributes, selfClose){
+ var result = '<' + name
+
+ Object.keys(attributes).forEach(function(key){
+ if (key.charAt !== '_'){
+ result += (' ' + key + '="' + escapeAttribute(attributes[key]) + '"' )
+ }
+ })
+
+ if (selfClose){
+ result += ' /'
+ }
+ return result + '>'
+}
+
+function closeTag(name){
+ return '</' + name + '>'
+}
245 shared/parse_view.js
@@ -0,0 +1,245 @@
+var sax = require('sax')
+var stripAttributes = ['t:repeat', 't:bind', 't:when', 't:if', 't:unless', 't:format', 't:by', 't:view', 't:content', 't:context']
+
+module.exports = function(rawView){
+ var templateCount = 1
+ , stack = createKeyStack()
+ , depth = 0
+ , discard = false
+
+ var view = {
+ elements: [],
+ sub: [],
+ referencedViews: [],
+ templates: {},
+ bindings: [],
+ _isView: true
+ }
+
+ var parser = sax.parser(false, {lowercasetags: true, normalize: true})
+
+ stack.push('elements', view.elements)
+ stack.push('template', view)
+
+ parser.onopentag = function(node){
+ depth++
+
+
+ if (elementIsTemplate(node)){
+ var template = createTemplate(node)
+ template.id = templateCount++
+
+ view.templates[template.id] = template
+
+ stack.get('template').sub.push(template.id)
+ stack.get('elements').push({template: template.id})
+
+
+ // push template and elements
+ stack.push('templateDepth', depth)
+ stack.push('template', template)
+ stack.push('elements', template.elements)
+
+ }
+
+ if (elementIsFilter(node)){
+
+ // push filter
+ stack.push('filter', node.attributes)
+
+ }
+
+ var element = createElement(node, {filter: stack.get('filter')})
+ stack.get('elements').push(element)
+
+ // add bindings to template
+ if (element[1]._bind){
+ setAdd(stack.get('template').bindings, element[1]._bind)
+ }
+ if (element[1]._filter){
+ setAddAll(stack.get('template').bindings, Object.keys(element[1]._filter))
+ }
+ Object.keys(node.attributes).forEach(function(key){
+ if (key.slice(0,7) === 't:bind:'){
+ setAdd(stack.get('template').bindings, node.attributes[key])
+ }
+ })
+
+ if (element[1]._view){
+ setAdd(view.referencedViews, element[1]._view.name)
+ }
+
+
+ // push elements
+ stack.push('elements', element[2] || []) // if no element collection, open a disconnected dump for items
+
+
+ }
+
+ parser.onclosetag = function(tag){
+ var isTemplate = (stack.get('templateDepth') === depth)
+ , isElement = (tag !== 'filter')
+ , isFilter = (tag === 'filter')
+
+ if (isTemplate){
+ stack.pop('template')
+ stack.pop('templateDepth')
+ stack.pop('elements')
+ }
+
+ if (!isFilter){
+ stack.pop('elements')
+ }
+
+ depth--
+ }
+
+ parser.ontext = function(text){
+ stack.get('elements').push(text)
+ }
+
+ parser.onscript = function(text){
+ stack.get('elements').push(text)
+ }
+
+ parser.write(rawView).close()
+
+ return view
+}
+
+function elementIsTemplate(node){
+ return !!node.attributes['t:repeat']
+}
+
+function elementIsFilter(node){
+ return !!node.attributes['t:by']
+}
+
+function createTemplate(node){
+ var template = {
+ query: node.attributes['t:repeat'],
+ sub: [],
+ bindings: [],
+ elements: []
+ }
+
+ return template
+}
+
+function checkAttributeAllowed(attr){
+ return !~stripAttributes.indexOf(attr) && attr.slice(0,7) !== 't:bind:'
+}
+
+function createElement(node, options){
+ // options: filter
+
+ var bound = false
+ var newAttributes = {}
+
+ Object.keys(node.attributes).forEach(function(key){
+ if (key === 't:behavior'){
+ newAttributes['data-behavior'] = node.attributes[key]
+ } else if (checkAttributeAllowed(key)){
+ newAttributes[key] = node.attributes[key]
+ } else if (key.slice(0,7) === 't:bind:'){
+ newAttributes._bindAttributes = newAttributes._bindAttributes || {}
+ newAttributes._bindAttributes[key.slice(7)] = node.attributes[key]
+ }
+ })
+
+ var element = [node.name, newAttributes]
+
+ // handle directly bound elements
+ if (node.attributes['t:bind']){
+ bound = true
+ element[1]._bind = node.attributes['t:bind']
+ }
+
+ if (node.attributes['t:view']){
+ element[1]._view = {name: node.attributes['t:view']}
+ if (node.attributes['t:context']){
+ element[1]._view.overrideLocalContext = node.attributes['t:context']
+ }
+ }
+
+ if (node.attributes['t:content']){
+ element[1]._content = true
+ }
+
+ if (node.attributes['t:format']){
+ element[1]._format = node.attributes['t:format']
+ }
+
+ // data by/when filter
+ if (node.attributes['t:when'] && (node.attributes['t:by'] || (options.filter && options.filter['t:by']))){
+ var filterParam = node.attributes['t:by'] || options.filter['t:by']
+
+ element[1]['_filters'] = element[1]['_filters'] || {}
+
+ // check to see if multimatch t:when
+ if (node.attributes['t:when'].indexOf('|') < 0){
+ element[1]['_filters'][filterParam] = node.attributes['t:when']
+ } else {
+ element[1]['_filters'][filterParam] = {$only: node.attributes['t:when'].split('|')}
+ }
+
+ }
+
+ // data if filter
+ if (node.attributes['t:if']){
+ element[1]['_filters'] = element[1]['_filters'] || {}
+ element[1]['_filters'][node.attributes['t:if']] = true
+ }
+
+ // data unless filter
+ if (node.attributes['t:unless']){
+ element[1]['_filters'] = element[1]['_filters'] || {}
+ element[1]['_filters'][node.attributes['t:unless']] = false
+ }
+
+
+ // if the element has been bound we don't want to keep the child elements
+ if (!bound){
+ element.push([])
+ }
+
+ return element
+}
+
+function setAdd(array, item){
+ if (array.indexOf(item) < 0){
+ array.push(item)
+ }
+}
+function setAddAll(array, items){
+ items.forEach(function(item){
+ setAdd(array, item)
+ })
+}
+
+
+function createKeyStack(){
+ var stacks = {}
+ , current = {}
+
+ var keyStack = {
+ push: function(param, value){
+ (stacks[param] = stacks[param] || []).push(current[param])
+ current[param] = value
+ },
+ pop: function(param){
+ if (stacks[param]){
+ current[param] = stacks[param].pop()
+ }
+
+ return current[param]
+ },
+ get: function(param){
+ return current[param]
+ },
+ count: function(param){
+ stacks[param] && (stacks[param].length + 1) || 0
+ }
+ }
+ return keyStack
+}
219 shared/render_template.js
@@ -0,0 +1,219 @@
+var checkFilter = require('./check_filter')
+module.exports = function(template, context, options){
+ // options: datasource, formatters, view, entityHandler
+ var viewRefStack = []
+ var views = options.view.views
+ var currentViewRef = null
+ var result = []
+ var formatters = options.formatters || {}
+ var datasource = options && options.datasource || {}
+ var bindingOptions = {datasource: datasource, context: context, formatters: formatters}
+
+ template.elements.forEach(function(x, i){
+ var meta = {}
+ if (options.includeBindingMetadata){
+ meta['data-tx'] = i
+ if (options.ti){
+ meta['data-ti'] = options.ti
+ }
+ }
+ renderElement(x, result, meta) // options.includeBindingMetadata && {'data-tx': i, 'data-ti': options.ti} || {})
+ })
+
+ function pushViewRef(viewRef){
+ viewRefStack.push(currentViewRef)
+ currentViewRef = viewRef
+ }
+ function popViewRef(){
+ var oldViewRef = currentViewRef
+ currentViewRef = viewRefStack.pop()
+ return oldViewRef
+ }
+
+ function renderElement(element, elements, extraAttributes){
+ extraAttributes = extraAttributes || {}
+
+ if (Array.isArray(element)){
+
+ var attributes = element[1]
+ , subElements = element[2] || []
+
+ // if it has filters, make sure they all pass
+ if (!attributes._filters || queryFilter(attributes._filters, context, datasource)){
+
+ var newElement = [element[0], {}, []]
+ bindAttributes(attributes, newElement, bindingOptions)
+
+ // add any extra attributes
+ Object.keys(extraAttributes).forEach(function(key){
+ newElement[1][key] = extraAttributes[key]
+ })
+
+ if (attributes._bind){
+ bindElement(element, newElement, bindingOptions)
+ } else if (attributes._view){
+
+ var view = views[attributes._view.name]
+ if (view){
+ pushViewRef(merge(attributes._view, {elements: subElements}))
+ view.elements.forEach(function(x, i){
+ renderElement(x, newElement[2], options.includeBindingMetadata && {'data-tx': i} || {})
+ })
+ popViewRef()
+ }
+
+ } else if (attributes._content){
+ if (currentViewRef){
+ var viewRef = currentViewRef
+ pushViewRef(viewRefStack[viewRefStack.length-1]) // push previous view as this is where the elements are from
+ viewRef.elements.forEach(function(x, i){
+ renderElement(x, newElement[2], options.includeBindingMetadata && {'data-tx': i} || {})
+ })
+ popViewRef()
+ }
+ } else {
+ // recursively render sub elements
+ subElements.forEach(function(x, i){
+ renderElement(x, newElement[2], options.includeBindingMetadata && {'data-tx': i} || {})
+ })
+ }
+
+
+ if (newElement[0] === 't:placeholder'){ // placeholder elements don't render... just their insides
+ appendPlaceholderElements(newElement, elements)
+ } else {
+ elements.push(newElement)
+ }
+
+ }
+
+ } else if (element instanceof Object){ // if it is not an element, pass thru e.g. text elements and templates
+ if (options.entityHandler){
+ options.entityHandler(element, elements, {viewRef: currentViewRef, context: context, tx: extraAttributes['data-tx']})
+ }
+ if (element.template != null){
+ elements.push({template: [(currentViewRef && currentViewRef.name || ''), element.template], _context: context})
+ } else {
+ elements.push(merge(element, {_context: context, _tx: extraAttributes['data-tx']}))
+ }
+ } else {
+ // text node
+ if (!appendText(element, elements)){
+ elements.push({text: element, _context: context, _tx: extraAttributes['data-tx']})
+ }
+
+ }
+
+ }
+
+ return result
+}
+
+function appendText(text, elements){
+ var lastElement = elements[elements.length-1]
+ if (lastElement && lastElement.text){
+ // merge with last textnode if a textnode and compress whitespace.
+ if (text !== ' ' || lastElement.text.slice(-1) !== ' '){
+ lastElement.text += text
+ }
+ return true
+ }
+}
+
+function appendPlaceholderElements(placeholder, elements){
+ placeholder[2].forEach(function(element){
+ if (Array.isArray(element)){
+ if (placeholder[1]['data-tx'] != null && element[1]['data-tx'] != null){
+ element[1]['data-tx'] = placeholder[1]['data-tx'] + '-' + element[1]['data-tx']
+ }
+ } else if (element instanceof Object){
+ if (placeholder[1]['data-tx'] != null && element._tx != null){
+ element._tx = placeholder[1]['data-tx'] + '-' + element._tx
+ }
+ }
+
+ if (!element.text || !appendText(element.text, elements)){
+ // check to make sure not a text double up
+ elements.push(element)
+ }
+ })
+}
+
+function queryFilter(filter, context, datasource){
+ var object = {}
+ Object.keys(filter).forEach(function(key){
+ if (key.lastIndexOf('.') === 0 && key.indexOf(':') === -1){
+ object[key] = context[key.slice(1)] // optimisation ... if standard key, skip the query
+ } else {
+ object[key] = datasource.query(key, context).value // or else query to get result
+ }
+ })
+ return checkFilter(object, filter)
+}
+
+function bindAttributes(attributes, destination, options){
+ //TODO: handle bound attributes
+
+ Object.keys(attributes).forEach(function(key){
+ if (key.charAt(0) !== '_'){
+ destination[1][key] = attributes[key]
+ }
+ })
+
+ attributes._bindAttributes && Object.keys(attributes._bindAttributes).forEach(function(key){
+ var value = options.datasource.get(attributes._bindAttributes[key], options.context)
+ if (value != null){
+ destination[1][key] = value.toString()
+ }
+ })
+
+}
+
+function assignTx(elements){
+ elements.forEach(function(element, i){
+ if (typeof element === 'string'){
+ element = elements[i] = {text: element}
+ }
+ if (Array.isArray(element)){
+ element[1]['data-tx'] = i
+ assignTx(element[2])
+ } else if (element instanceof Object) {
+ element._tx = i
+ }
+ })
+}
+
+function bindElement(templateElement, destination, options){
+ var attributes = templateElement[1]
+ var value = options.datasource.query(attributes._bind, options.context).value
+ if (attributes._format && options.formatters[attributes._format]){ // use a formatter if specified
+ var res = options.formatters[attributes._format](value)
+ if (res){
+ assignTx(res)
+ res.forEach(function(element){
+ destination[2].push(element)
+ })
+ }
+ } else {
+ // straight text display
+ destination[2].push({text: value, _tx: 0})
+ }
+}
+
+function merge(a,b){
+
+ if (!a){
+ return b || {}
+ } else if (!b){
+ return a || {}
+ }
+
+ var result = {}
+ Object.keys(a).forEach(function(k){
+ result[k] = a[k]
+ })
+ Object.keys(b).forEach(function(k){
+ result[k] = b[k]
+ })
+ return result
+}
90 shared/render_view.js
@@ -0,0 +1,90 @@
+var renderTemplate = require('./render_template')
+
+module.exports = function(view, datasource, options){
+ // options: formatters, includeBindingMetadata
+ options = options || {}
+
+ // resolve views if not already
+ if (options.views && !view.views){
+ view = mergeClone(view, {views: resolveSubViewsFor(view, options.views)})
+ }
+
+ var elements = renderTemplate(view, datasource.context, {
+ datasource: datasource,
+ formatters: options.formatters,
+ view: view,
+ includeBindingMetadata: options.includeBindingMetadata,
+ entityHandler: entityHandler
+ })
+
+ function entityHandler(entity, elements, params){
+ // TODO: Handle view switch correctly
+
+ if (entity.template){
+ // render sub template
+ var currentView = params.viewRef && view.views[params.viewRef.name] || view
+ var template = currentView.templates[entity.template]
+
+ var collection = datasource.get(template.query, params.context)
+ if (collection){
+ collection.forEach(function(item, i){
+ renderTemplate(template, item, {
+ datasource: datasource,
+ formatters: options.formatters,
+ includeBindingMetadata: options.includeBindingMetadata,
+ view: view,
+ ti: (params.viewRef && params.viewRef.name || '') + ':' + entity.template + ':' + i,
+ entityHandler: entityHandler
+ }).forEach(function(element){
+ elements.push(element)
+ })
+ })
+ }
+ }
+ }
+
+ if (options.includeBindingMetadata){
+
+
+ elements.push(['script', {'type': 'application/json', id: 'realtimeBindingInfo'}, [JSON.stringify({data: datasource.data, matchers: datasource.matchers, view: view})]])
+ }
+
+ return elements
+}
+
+function resolveSubViewsFor(view, views){
+ var resolvedViews = {}
+ view.referencedViews.forEach(function(viewName){
+ var subView = views[viewName]
+ if (subView){
+ resolvedViews[viewName] = views[viewName]
+ mergeInto(resolveSubViewsFor(subView, views), resolvedViews)
+ }
+ })
+ return resolvedViews
+}
+
+function mergeClone(){
+ var result = {}
+ for (var i=0;i<arguments.length;i++){
+ var obj = arguments[i]
+ if (obj){
+ Object.keys(obj).forEach(function(key){
+ result[key] = obj[key]
+ })
+ }
+ }
+ return result
+}
+
+function mergeInto(a,b){
+
+ var result = b
+ if (a){
+ Object.keys(a).forEach(function(k){
+ result[k] = a[k]
+ })
+ }
+
+ return result
+}
45 test/parse_view.test.js
@@ -0,0 +1,45 @@
+var test = require('tap').test
+var util = require('util')
+var parseView = require('../shared/parse_view')
+
+test("Parse standard elements", function(t){
+ var view = "<div>Contents <strong>Some sub content</strong></div>"
+ var expected = {
+ elements:
+ [[
+ 'div',{},[ 'Contents ', [ 'strong', {}, [ 'Some sub content' ] ] ]
+ ]],
+ sub: [],
+ referencedViews: [],
+ templates: {},
+ bindings: [],
+ _isView: true
+ }
+ t.deepEqual(parseView(view), expected)
+ t.end()
+})
+
+// TODO: More tests
+
+test("Parse standard elements with inner view", function(t){
+ var view = "<div>Contents <strong>Some sub content</strong> <placeholder t:view='inline_item'/></div>"
+ //console.log(util.inspect(parseView(view), false, 10))
+
+ var expected = {
+ elements:
+ [
+ ['div',{},[
+ 'Contents ',['strong', {},['Some sub content']],' ',['placeholder',{
+ _view: {name: 'inline_item'}
+ },[]]]
+ ]
+ ],
+ sub: [],
+ referencedViews: ['inline_item'],
+ templates: {},
+ bindings: [],
+ _isView: true
+ }
+ t.deepEqual(parseView(view), expected)
+ t.end()
+})
Please sign in to comment.
Something went wrong with that request. Please try again.