Skip to content
Browse files

convert to js

  • Loading branch information...
1 parent df04564 commit 4dc82069e75944d2f7709046dc719380b3c1a009 @nateps nateps committed
View
5 .gitignore
@@ -1,6 +1,3 @@
+/bin/lib
node_modules
-lib
-gen
test-output.tmp
-examples/hello/*.js
-*.gz
View
4 Makefile
@@ -1,7 +1,7 @@
compile:
- ./node_modules/racer/node_modules/coffee-script/bin/coffee -bw -o ./lib -c ./src
+ ./node_modules/coffee-script/bin/coffee -bw -o ./bin/lib -c ./bin/src
-MOCHA_TESTS := $(shell find test/ -name '*.mocha.coffee')
+MOCHA_TESTS := $(shell find test/ -name '*.mocha.*')
MOCHA := ./node_modules/racer/node_modules/mocha/bin/mocha
OUT_FILE = "test-output.tmp"
View
3 bin/derby
@@ -1,3 +1,2 @@
#!/usr/bin/env node
-
-require('../lib/bin/derby');
+require('./lib/derby');
View
711 bin/src/derby.coffee
@@ -0,0 +1,711 @@
+{exec} = require 'child_process'
+program = require 'commander'
+mkdirp = require 'mkdirp'
+fs = require 'fs'
+{join, resolve, basename} = require 'path'
+derby = require '../../lib/derby'
+
+
+## TEMPLATES ##
+
+APP_COFFEE = '''
+{get, view, ready} = require('derby').createApp module
+
+## ROUTES ##
+
+start = +new Date()
+
+# Derby routes can be rendered on the client and the server
+get '/:roomName?', (page, model, {roomName}) ->
+ roomName ||= 'home'
+
+ # Subscribes the model to any updates on this room's object. Calls back
+ # with a scoped model equivalent to:
+ # room = model.at "rooms.#{roomName}"
+ model.subscribe "rooms.#{roomName}", (err, room) ->
+ model.ref '_room', room
+
+ # setNull will set a value if the object is currently null or undefined
+ room.setNull 'welcome', "Welcome to #{roomName}!"
+
+ room.incr 'visits'
+
+ # This value is set for when the page initially renders
+ model.set '_timer', '0.0'
+ # Reset the counter when visiting a new route client-side
+ start = +new Date()
+
+ # Render will use the model data as well as an optional context object
+ page.render
+ roomName: roomName
+ randomUrl: parseInt(Math.random() * 1e9).toString(36)
+
+
+## CONTROLLER FUNCTIONS ##
+
+ready (model) ->
+ timer = null
+
+ # Expose the model as a global variable in the browser. This is fun in
+ # development, but it should be removed when writing an app
+ window.model = model
+
+ # Exported functions are exposed as a global in the browser with the same
+ # name as the module that includes Derby. They can also be bound to DOM
+ # events using the "x-bind" attribute in a template.
+ exports.stop = ->
+
+ # Any path name that starts with an underscore is private to the current
+ # client. Nothing set under a private path is synced back to the server.
+ model.set '_stopped', true
+ clearInterval timer
+
+ do exports.start = ->
+ model.set '_stopped', false
+ timer = setInterval ->
+ model.set '_timer', (((+new Date()) - start) / 1000).toFixed(1)
+ , 100
+
+
+ model.set '_showReconnect', true
+ exports.connect = ->
+ # Hide the reconnect link for a second after clicking it
+ model.set '_showReconnect', false
+ setTimeout (-> model.set '_showReconnect', true), 1000
+ model.socket.socket.connect()
+
+ exports.reload = -> window.location.reload()
+
+'''
+
+APP_JS = '''
+var <<app>> = require('derby').createApp(module)
+ , get = <<app>>.get
+ , view = <<app>>.view
+ , ready = <<app>>.ready
+ , start
+
+// ROUTES //
+
+start = +new Date()
+
+// Derby routes can be rendered on the client and the server
+get('/:roomName?', function(page, model, params) {
+ var roomName = params.roomName || 'home'
+
+ // Subscribes the model to any updates on this room's object. Calls back
+ // with a scoped model equivalent to:
+ // room = model.at('rooms.' + roomName)
+ model.subscribe('rooms.' + roomName, function(err, room) {
+ model.ref('_room', room)
+
+ // setNull will set a value if the object is currently null or undefined
+ room.setNull('welcome', 'Welcome to ' + roomName + '!')
+
+ room.incr('visits')
+
+ // This value is set for when the page initially renders
+ model.set('_timer', '0.0')
+ // Reset the counter when visiting a new route client-side
+ start = +new Date()
+
+ // Render will use the model data as well as an optional context object
+ page.render({
+ roomName: roomName
+ , randomUrl: parseInt(Math.random() * 1e9).toString(36)
+ })
+ })
+})
+
+
+// CONTROLLER FUNCTIONS //
+
+ready(function(model) {
+ var timer
+
+ // Expose the model as a global variable in the browser. This is fun in
+ // development, but it should be removed when writing an app
+ window.model = model
+
+ // Exported functions are exposed as a global in the browser with the same
+ // name as the module that includes Derby. They can also be bound to DOM
+ // events using the "x-bind" attribute in a template.
+ exports.stop = function() {
+
+ // Any path name that starts with an underscore is private to the current
+ // client. Nothing set under a private path is synced back to the server.
+ model.set('_stopped', true)
+ clearInterval(timer)
+ }
+
+ exports.start = function() {
+ model.set('_stopped', false)
+ timer = setInterval(function() {
+ model.set('_timer', (((+new Date()) - start) / 1000).toFixed(1))
+ }, 100)
+ }
+ exports.start()
+
+
+ model.set('_showReconnect', true)
+ exports.connect = function() {
+ // Hide the reconnect link for a second after clicking it
+ model.set('_showReconnect', false)
+ setTimeout(function() {
+ model.set('_showReconnect', true)
+ }, 1000)
+ model.socket.socket.connect()
+ }
+
+ exports.reload = function() {
+ window.location.reload()
+ }
+
+})
+
+'''
+
+SERVER_COFFEE = '''
+http = require 'http'
+path = require 'path'
+express = require 'express'
+gzippo = require 'gzippo'
+derby = require 'derby'
+<<app>> = require '../<<app>>'
+serverError = require './serverError'
+
+
+## SERVER CONFIGURATION ##
+
+ONE_YEAR = 1000 * 60 * 60 * 24 * 365
+root = path.dirname path.dirname __dirname
+publicPath = path.join root, 'public'
+
+(expressApp = express())
+ .use(express.favicon())
+ # Gzip static files and serve from memory
+ .use(gzippo.staticGzip publicPath, maxAge: ONE_YEAR)
+
+ # Gzip dynamically rendered content
+ .use(express.compress())
+
+ # Uncomment to add form data parsing support
+ # .use(express.bodyParser())
+ # .use(express.methodOverride())
+
+ # Derby session middleware creates req.model and subscribes to _session
+ # .use(express.cookieParser 'secret_sauce')
+ # .use(express.session
+ # cookie: {maxAge: ONE_YEAR}
+ # )
+ # .use(<<app>>.session())
+
+ # The router method creates an express middleware from the app's routes
+ .use(<<app>>.router())
+ .use(expressApp.router)
+ .use(serverError root)
+
+module.exports = server = http.createServer expressApp
+
+
+## SERVER ONLY ROUTES ##
+
+expressApp.all '*', (req) ->
+ throw "404: #{req.url}"
+
+
+## STORE SETUP ##
+
+store = <<app>>.createStore listen: server
+
+'''
+
+SERVER_JS = '''
+var http = require('http')
+ , path = require('path')
+ , express = require('express')
+ , gzippo = require('gzippo')
+ , derby = require('derby')
+ , <<app>> = require('../<<app>>')
+ , serverError = require('./serverError')
+
+
+// SERVER CONFIGURATION //
+
+var ONE_YEAR = 1000 * 60 * 60 * 24 * 365
+ , root = path.dirname(path.dirname(__dirname))
+ , publicPath = path.join(root, 'public')
+ , expressApp, server, store
+
+;(expressApp = express())
+ .use(express.favicon())
+ // Gzip static files and serve from memory
+ .use(gzippo.staticGzip(publicPath, {maxAge: ONE_YEAR}))
+
+ // Gzip dynamically rendered content
+ .use(express.compress())
+
+ // Uncomment to add form data parsing support
+ // .use(express.bodyParser())
+ // .use(express.methodOverride())
+
+ // Derby session middleware creates req.model and subscribes to _session
+ // .use(express.cookieParser('secret_sauce'))
+ // .use(express.session({
+ // cookie: {maxAge: ONE_YEAR}
+ // })
+ // .use(<<app>>.session())
+
+ // The router method creates an express middleware from the app's routes
+ .use(<<app>>.router())
+ .use(expressApp.router)
+ .use(serverError(root))
+
+module.exports = server = http.createServer(expressApp)
+
+
+// SERVER ONLY ROUTES //
+
+expressApp.all('*', function(req) {
+ throw '404: ' + req.url
+})
+
+
+// STORE SETUP //
+
+store = <<app>>.createStore({listen: server})
+
+'''
+
+SERVER_ERROR_JS = '''
+var derby = require('derby')
+ , isProduction = derby.util.isProduction
+
+module.exports = function(root) {
+ var staticPages = derby.createStatic(root)
+
+ return function(err, req, res, next) {
+ if (err == null) return next()
+
+ console.log(err.stack ? err.stack : err)
+
+ // Customize error handling here
+ var message = err.message || err.toString()
+ , status = parseInt(message)
+ if (status === 404) {
+ staticPages.render('404', res, {url: req.url}, 404)
+ } else {
+ res.send( ((status >= 400) && (status < 600)) ? status : 500)
+ }
+ }
+}
+
+'''
+
+SERVER_ERROR_COFFEE = '''
+derby = require 'derby'
+{isProduction} = derby.util
+
+module.exports = (root) ->
+ staticPages = derby.createStatic root
+
+ return (err, req, res, next) ->
+ return next() unless err?
+
+ console.log(if err.stack then err.stack else err)
+
+ ## Customize error handling here ##
+ message = err.message || err.toString()
+ status = parseInt message
+ if status is 404
+ staticPages.render '404', res, {url: req.url}, 404
+ else
+ res.send if 400 <= status < 600 then status else 500
+
+'''
+
+APP_HTML = '''
+<!--
+ Derby templates are similar to Handlebars, except that they are first
+ parsed as HTML, and there are a few extensions to make them work directly
+ with models. A single HTML template defines the HTML output, the event
+ handlers that update the model after user interaction, and the event handlers
+ that update the DOM when the model changes.
+
+ As in Handlebars, double curly braces output a value literally. Derby
+ templates add single curly braces, which output a value and set up
+ model <- -> view bindings for that object.
+
+ Elements that end in colon define template names. Pre-defined templates
+ are capitalized by convention, but template names are case-insensitive.
+ Pre-defined templates are automatically included when the page is rendered.
+-->
+
+<Title:>
+ {{roomName}} - {_room.visits} visits
+
+<Header:>
+ <!-- Other templates are referenced like custom HTML elements -->
+ <app:alert>
+
+<Body:>
+ <h1>{_room.welcome}</h1>
+ <p><label>Welcome message: <input value="{_room.welcome}"></label></p>
+
+ <p>This page has been visted {_room.visits} times. <app:timer></p>
+
+ <p>Let's go <a href="/{{randomUrl}}">somewhere random</a>.</p>
+
+<timer:>
+ {#if _stopped}
+ <a x-bind="click:start">Start timer</a>
+ {else}
+ You have been here for {_timer} seconds. <a x-bind="click:stop">Stop</a>
+ {/}
+
+<!--
+ connected and canConnect are built-in properties of model. If a variable
+ is not defined in the current context, it will be looked up in the model
+ data and the model properties
+-->
+<alert:>
+ <div id="alert">
+ {#unless connected}
+ <p>
+ {#if canConnect}
+ <!-- Leading space is removed, and trailing space is maintained -->
+ Offline
+ {#if _showReconnect}&ndash; <a x-bind="click:connect">Reconnect</a>{/}
+ {else}
+ Unable to reconnect &ndash; <a x-bind="click:reload">Reload</a>
+ {/}
+ </p>
+ {/}
+ </div>
+
+'''
+
+_404_HTML = '''
+<!--
+ This is a static template file, so it doesn't have an associated app.
+ It is rendered by the server via a staticPages renderer.
+
+ Since static pages don't include the Derby client library, they can't have
+ bound variables that automatically update. However, they do support initial
+ template tag rendering from a context object and/or model.
+-->
+
+<Title:>
+ Not found
+
+<Body:>
+ <h1>404</h1>
+ <p>Sorry, we can't find anything at <b>{{url}}</b>.
+ <p>Try heading back to the <a href="/">home page</a>.
+
+'''
+
+RESET_STYL = '''
+body,h1,h2,h3,h4,th {
+ font: 13px/normal Arial,sans-serif;
+}
+body {
+ background: #fff;
+ color: #000;
+}
+body,fieldset,form,h1,h2,h3,h4,li,ol,p,td,th,ul {
+ margin: 0;
+ padding: 0;
+}
+ul {
+ margin: 0 normal;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+fieldset,img {
+ border: 0;
+}
+
+'''
+
+BASE_STYL = '''
+@import "./reset";
+@import "nib/vendor";
+
+body {
+ padding: 2em;
+}
+h1 {
+ font-size: 2em;
+ margin-bottom: .5em;
+}
+p {
+ line-height: 2em;
+}
+
+'''
+
+APP_STYL = '''
+@import "../base";
+
+#alert {
+ position: absolute;
+ text-align: center;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 0;
+ z-index: 99;
+}
+#alert > p {
+ background: #fff1a8;
+ border: 1px solid #999;
+ border-top: 0;
+ border-radius: 0 0 3px 3px;
+ display: inline-block;
+ line-height: 21px;
+ padding: 0 12px;
+}
+
+'''
+
+_404_STYL = '''
+@import "./base";
+
+'''
+
+SERVER = '''
+require('derby').run(__dirname + '/lib/server')
+
+'''
+
+MAKEFILE_COFFEE = '''
+compile:
+ ./node_modules/coffee-script/bin/coffee -bw -o ./lib -c ./src
+
+'''
+
+README = '''
+# <<project>>
+
+'''
+
+GITIGNORE_COFFEE = '''
+.DS_Store
+public/gen
+lib/
+*.swp
+
+'''
+
+GITIGNORE_JS = '''
+.DS_Store
+public/gen
+*.swp
+
+'''
+
+packageJson = (project, useCoffee) ->
+ pkg =
+ name: project
+ description: ''
+ version: '0.0.0'
+ main: './server.js'
+ dependencies:
+ derby: '*'
+ express: '3.x'
+ gzippo: '>=0.1.4'
+ private: true
+
+ if useCoffee
+ pkg.devDependencies =
+ 'coffee-script': '>=1.2'
+
+ return JSON.stringify pkg, null, ' '
+
+
+## COMMANDS ##
+
+printUsage = true
+
+# Adapted from https://github.com/loopj/commonjs-ansi-color
+ANSI_CODES =
+ 'off': 0
+ 'bold': 1
+ 'italic': 3
+ 'underline': 4
+ 'blink': 5
+ 'inverse': 7
+ 'hidden': 8
+ 'black': 30
+ 'red': 31
+ 'green': 32
+ 'yellow': 33
+ 'blue': 34
+ 'magenta': 35
+ 'cyan': 36
+ 'white': 37
+ 'black_bg': 40
+ 'red_bg': 41
+ 'green_bg': 42
+ 'yellow_bg': 43
+ 'blue_bg': 44
+ 'magenta_bg': 45
+ 'cyan_bg': 46
+ 'white_bg': 47
+
+styleTag = (name) -> "\u001b[#{ANSI_CODES[name]}m"
+
+style = (styles, text) ->
+ styles = styles.split ' '
+ out = ''
+ out += styleTag style for style in styles
+ return out + text + styleTag('off')
+
+emptyDirectory = (path, callback) ->
+ fs.readdir path, (err, files) ->
+ throw err if err && err.code isnt 'ENOENT'
+ callback !files || !files.length
+
+makeCallback = (path, callback) ->
+ (err) ->
+ throw err if err
+ console.log style('green', ' created: ') + path
+ callback() if callback
+
+mkdir = (path, callback) ->
+ mkdirp path, '0755', makeCallback(path, callback)
+
+writeFile = (path, text, callback) ->
+ fs.writeFile path, text, makeCallback(path, callback)
+
+render = (template, ctx) ->
+ for key, value of ctx
+ re = new RegExp '<<' + key + '>>', 'g'
+ template = template.replace re, value
+ return template
+
+abort = (message) ->
+ message ||= style 'red bold', '\n Aborted \n'
+ console.error message
+ process.exit 1
+
+
+createProject = (dir, app, useCoffee) ->
+ dirPath = resolve process.cwd(), dir
+ unless project = basename dirPath
+ throw new Error 'Cannot create project at ' + dirPath
+ views = join dir, 'views'
+ styles = join dir, 'styles'
+ scripts = if useCoffee then join dir, 'src' else join dir, 'lib'
+ appViews = join views, app
+ appStyles = join styles, app
+ appScripts = join scripts, app
+ serverScripts = join scripts, 'server'
+
+ logComplete = ->
+ message = style('green bold', '\n Project created!') + '\n\n Try it out:'
+ message += "\n $ cd #{dir}" if dir != '.'
+ message += '\n $ npm install' if program.noinstall
+ if useCoffee
+ message += """
+ \n $ make
+
+ Then in a new terminal:
+ $ cd #{dirPath}
+ """
+ message += """
+ \n $ node server.js
+
+ More info at: http://derbyjs.com/
+
+ """
+ console.log message
+
+ finish = ->
+ return logComplete() if program.noinstall
+ process.chdir dir
+ console.log '\n Installing dependencies. This may take a little while...'
+ exec 'npm install', (err, stdout, stderr) ->
+ return console.error stderr if err
+ console.log stdout.replace /^|\n/g, '\n ' if stdout
+ logComplete()
+
+ count = 0
+ wait = (callback) ->
+ count++
+ return ->
+ callback() if callback
+ finish() unless --count
+
+ mkdir dir, ->
+ mkdir join(dir, 'public', 'img'), wait()
+ mkdir appViews, wait ->
+ writeFile join(appViews, 'index.html'), APP_HTML, wait()
+ writeFile join(views, '404.html'), _404_HTML, wait()
+ mkdir appStyles, wait ->
+ writeFile join(appStyles, 'index.styl'), APP_STYL, wait()
+ writeFile join(styles, '404.styl'), _404_STYL, wait()
+ writeFile join(styles, 'reset.styl'), RESET_STYL, wait()
+ writeFile join(styles, 'base.styl'), BASE_STYL, wait()
+
+ if useCoffee
+ mkdir appScripts, wait ->
+ writeFile join(appScripts, 'index.coffee'), render(APP_COFFEE, {app}), wait()
+ mkdir serverScripts, wait ->
+ writeFile join(serverScripts, 'index.coffee'), render(SERVER_COFFEE, {app}), wait()
+ writeFile join(serverScripts, 'serverError.coffee'), render(SERVER_ERROR_COFFEE, {app}), wait()
+ writeFile join(dir, 'Makefile'), MAKEFILE_COFFEE, wait()
+ writeFile join(dir, '.gitignore'), GITIGNORE_COFFEE, wait()
+ else
+ mkdir appScripts, wait ->
+ writeFile join(appScripts, 'index.js'), render(APP_JS, {app}), wait()
+ mkdir serverScripts, wait ->
+ writeFile join(serverScripts, 'index.js'), render(SERVER_JS, {app}), wait()
+ writeFile join(serverScripts, 'serverError.js'), render(SERVER_ERROR_JS, {app}), wait()
+ writeFile join(dir, '.gitignore'), GITIGNORE_JS, wait()
+
+ writeFile join(dir, 'server.js'), SERVER, wait()
+ writeFile join(dir, 'package.json'), packageJson(project, useCoffee), wait()
+ writeFile join(dir, 'README.md'), render(README, {project}), wait()
+
+newProject = (dir = '.', app = 'app') ->
+ printUsage = false
+ useCoffee = program.coffee
+
+ type = if useCoffee then 'CoffeeScript ' else ''
+ directory = style 'bold',
+ if dir is '.' then 'the current directory' else dir
+ console.log "\n Creating #{type}project in #{directory} with the application " +
+ style('bold', app) + '\n'
+
+ emptyDirectory dir, (empty) ->
+ unless empty
+ return program.confirm ' Destination is not empty. Continue? ', (ok) ->
+ abort() unless ok
+ process.stdin.destroy()
+ createProject dir, app, useCoffee
+
+ createProject dir, app, useCoffee
+
+
+## CLI ##
+
+program
+ .version(derby.version)
+ .option('-c, --coffee', 'create files using CoffeeScript')
+ .option('-n, --noinstall', "don't run `npm install`")
+
+program
+ .command('new [dir] [app]')
+ .description('''
+ \nCreate a new Derby project. If no directory name is specified, or the
+ name `.` is used, the project will be created in the current directory.
+ A name for the default app may be specified optionally.''')
+ .action(newProject)
+
+program.parse process.argv
+
+console.log '\n See `derby --help` for usage\n' if printUsage
View
372 lib/Dom.js
@@ -0,0 +1,372 @@
+var racer = require('racer')
+ , domShim = require('dom-shim')
+ , lookup = require('racer/lib/path').lookup
+ , EventDispatcher = require('./EventDispatcher')
+ , escapeHtml = require('html-util').escapeHtml
+ , merge = racer.util.merge
+ , win = window
+ , doc = document
+ , markers = {}
+ , elements = {
+ $_win: win
+ , $_doc: doc
+ }
+ , addListener, removeListener;
+
+module.exports = Dom;
+
+function Dom(model, appExports) {
+ var dom = this
+ , fns = this.fns
+
+ // Map dom event name -> true
+ , listenerAdded = {}
+ , captureListenerAdded = {};
+
+
+ // DOM listener capturing allows blur and focus to be delegated
+ // http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html
+
+ var events = this._events = new EventDispatcher({
+ onTrigger: onTrigger
+ , onBind: function(name, listener, eventName) {
+ if (!listenerAdded[eventName]) {
+ addListener(doc, eventName, trigger, true);
+ listenerAdded[eventName] = true;
+ }
+ }
+ });
+
+ var captureEvents = this._captureEvents = new EventDispatcher({
+ onTrigger: function(name, listener, e) {
+ var el = doc.getElementById(id)
+ , id = listener.id;
+ if (el.tagName === 'HTML' || el.contains(e.target)) {
+ onTrigger(name, listener, id, e, el);
+ }
+ }
+ , onBind: function(name, listener) {
+ if (!captureListenerAdded[name]) {
+ addListener(doc, name, captureTrigger, true);
+ captureListenerAdded[name] = true;
+ }
+ }
+ });
+
+ function onTrigger(name, listener, id, e, el, next) {
+ var fn = listener.fn
+ , delay = listener.delay
+ , finish;
+
+ if (fn != null) {
+ finish = fns[fn] || appExports[fn] || lookup(fn, appExports);
+ if (!finish) return;
+
+ } else {
+ // Update the model when the element's value changes
+ finish = function() {
+ var value = dom.getMethods[listener.method](el, listener.property)
+ , setValue = listener.setValue;
+
+ // Allow the listener to override the setting function
+ if (setValue) {
+ setValue(model, value);
+ return;
+ }
+
+ // Remove this listener if its path id is no longer registered
+ var path = model.__pathMap.paths[listener.pathId];
+ if (!path) return false;
+
+ // Set the value if changed
+ if (model.get(path) === value) return;
+ model.pass(e).set(path, value);
+ }
+ }
+
+ if (delay != null) {
+ setTimeout(finish, delay, e, el, next, dom);
+ } else {
+ finish(e, el, next, dom);
+ }
+ }
+
+ function trigger(e, el, noBubble, continued) {
+ if (!el) el = e.target;
+ var prefix = e.type + ':'
+ , id;
+
+ // Next can be called from a listener to continue bubbling
+ function next() {
+ trigger(e, el.parentNode, false, true);
+ }
+ next.firstTrigger = !continued;
+ if (noBubble && (id = el.id)) {
+ return events.trigger(prefix + id, id, e, el, next);
+ }
+ while (true) {
+ while (!(id = el.id)) {
+ if (!(el = el.parentNode)) return;
+ }
+ // Stop bubbling once the event is handled
+ if (events.trigger(prefix + id, id, e, el, next)) return;
+ if (!(el = el.parentNode)) return;
+ }
+ }
+
+ function captureTrigger(e) {
+ captureEvents.trigger(e.type, e);
+ }
+
+ this.trigger = trigger;
+ this.captureTrigger = captureTrigger;
+ this.addListener = addListener;
+ this.removeListener = removeListener;
+}
+
+Dom.prototype = {
+ clear: function() {
+ this._events.clear();
+ this._captureEvents.clear();
+ markers = {};
+ }
+
+, bind: function(eventName, id, listener) {
+ if (listener.capture) {
+ listener.id = id;
+ this._captureEvents.bind(eventName, listener);
+ } else {
+ this._events.bind("" + eventName + ":" + id, listener, eventName);
+ }
+ }
+
+, update: function(el, method, ignore, value, property, index) {
+ // Don't do anything if the element is already up to date
+ if (value === this.getMethods[method](el, property)) return;
+ this.setMethods[method](el, ignore, value, property, index);
+ }
+
+, item: function(id) {
+ return doc.getElementById(id) || elements[id] || getRange(id);
+ }
+
+, getMethods: {
+ attr: getAttr
+ , prop: getProp
+ , propPolite: getProp
+ , html: getHtml
+ // These methods return NaN, because it never equals anything else. Thus,
+ // when compared against the new value, the new value will always be set
+ , append: getNaN
+ , insert: getNaN
+ , remove: getNaN
+ , move: getNaN
+ }
+
+, setMethods: {
+ attr: setAttr
+ , prop: setProp
+ , propPolite: setProp
+ , html: setHtml
+ , append: setAppend
+ , insert: setInsert
+ , remove: setRemove
+ , move: setMove
+ }
+
+, fns: {
+ $forChildren: forChildren
+ , $forName: forName
+ }
+}
+
+
+function getAttr(el, attr) {
+ return el.getAttribute(attr);
+}
+function getProp(el, prop) {
+ return el[prop];
+}
+function getHtml(el) {
+ return el.innerHTML;
+}
+function getNaN() {
+ return NaN;
+}
+
+function setAttr(el, ignore, value, attr) {
+ if (ignore && el.id === ignore) return;
+ el.setAttribute(attr, value);
+}
+function setProp(el, ignore, value, prop) {
+ if (ignore && el.id === ignore) return;
+ el[prop] = value;
+}
+function propPolite(el, ignore, value, prop) {
+ if (ignore && el.id === ignore) return;
+ if (el !== doc.activeElement || !doc.hasFocus()) {
+ el[prop] = value;
+ }
+}
+function setHtml(obj, ignore, value, escape) {
+ if (escape) value = escapeHtml(value);
+ if (obj.nodeType) {
+ // Element
+ if (ignore && obj.id === ignore) return;
+ obj.innerHTML = value;
+ } else {
+ // Range
+ obj.deleteContents();
+ obj.insertNode(obj.createContextualFragment(value));
+ }
+}
+function setAppend(obj, ignore, value, escape) {
+ if (escape) value = escapeHtml(value);
+ if (obj.nodeType) {
+ // Element
+ obj.insertAdjacentHTML('beforeend', value);
+ } else {
+ // Range
+ var el = obj.endContainer
+ , ref = el.childNodes[obj.endOffset];
+ el.insertBefore(obj.createContextualFragment(value), ref);
+ }
+}
+function setInsert(obj, ignore, value, escape, index) {
+ if (escape) value = escapeHtml(value);
+ if (obj.nodeType) {
+ // Element
+ if (ref = obj.childNodes[index]) {
+ ref.insertAdjacentHTML('beforebegin', value);
+ } else {
+ obj.insertAdjacentHTML('beforeend', value);
+ }
+ } else {
+ // Range
+ var el = obj.startContainer
+ , ref = el.childNodes[obj.startOffset + index];
+ el.insertBefore(obj.createContextualFragment(value), ref);
+ }
+}
+function setRemove(el, ignore, index) {
+ if (!el.nodeType) {
+ // Range
+ index += el.startOffset;
+ el = el.startContainer;
+ }
+ var child = el.childNodes[index];
+ if (child) el.removeChild(child);
+}
+function setMove(el, ignore, from, to, howMany) {
+ var child, fragment, nextChild, offset, ref, toEl;
+ if (!el.nodeType) {
+ offset = el.startOffset;
+ from += offset;
+ to += offset;
+ el = el.startContainer;
+ }
+ child = el.childNodes[from];
+
+ // Don't move if the item at the destination is passed as the ignore
+ // option, since this indicates the intended item was already moved
+ // Also don't move if the child to move matches the ignore option
+ if (!child || ignore && (toEl = el.childNodes[to]) &&
+ toEl.id === ignore || child.id === ignore) return;
+
+ ref = el.childNodes[to > from ? to + howMany : to];
+ if (howMany > 1) {
+ fragment = document.createDocumentFragment();
+ while (howMany--) {
+ nextChild = child.nextSibling;
+ fragment.appendChild(child);
+ if (!(child = nextChild)) break;
+ }
+ el.insertBefore(fragment, ref);
+ return;
+ }
+ el.insertBefore(child, ref);
+}
+
+function forChildren(e, el, next, dom) {
+ // Prevent infinte emission
+ if (!next.firstTrigger) return;
+
+ // Re-trigger the event on all child elements
+ var children = el.childNodes;
+ for (var i = 0, len = children.length, child; i < len; i++) {
+ child = children[i];
+ if (child.nodeType !== 1) continue; // Node.ELEMENT_NODE
+ dom.trigger(e, child, true, true);
+ forChildren(e, child, next, dom);
+ }
+}
+
+function forName(e, el, next, dom) {
+ // Prevent infinte emission
+ if (!next.firstTrigger) return;
+
+ var name = el.getAttribute('name');
+ if (!name) return;
+
+ // Re-trigger the event on all other elements with
+ // the same 'name' attribute
+ var elements = doc.getElementsByName(name)
+ , len = elements.length;
+ if (!(len > 1)) return;
+ for (var i = 0, element; i < len; i++) {
+ element = elements[i];
+ if (element === el) continue;
+ dom.trigger(e, element, false, true);
+ }
+}
+
+function getRange(name) {
+ var start = markers[name]
+ , end = markers['$' + name]
+ , comment, commentIterator, range;
+
+ if (!(start && end)) {
+ // NodeFilter.SHOW_COMMENT == 128
+ commentIterator = doc.createTreeWalker(doc.body, 128, null, false);
+ while (comment = commentIterator.nextNode()) {
+ markers[comment.data] = comment;
+ }
+ start = markers[name];
+ end = markers['$' + name];
+ if (!(start && end)) return;
+ }
+
+ // Comment nodes may continue to exist even if they have been removed from
+ // the page. Thus, make sure they are still somewhere in the page body
+ if (!doc.body.contains(start)) {
+ delete markers[name];
+ delete markers['$' + name];
+ return;
+ }
+ range = doc.createRange();
+ range.setStartAfter(start);
+ range.setEndBefore(end);
+ return range;
+}
+
+if (doc.addEventListener) {
+ addListener = function(el, name, callback, captures) {
+ el.addEventListener(name, callback, captures || false);
+ };
+ removeListener = function(el, name, callback, captures) {
+ el.removeEventListener(name, callback, captures || false);
+ };
+
+} else if (doc.attachEvent) {
+ addListener = function(el, name, callback) {
+ function listener() {
+ if (!event.target) event.target = event.srcElement;
+ callback(event);
+ }
+ callback.$derbyListener = listener;
+ el.attachEvent('on' + name, listener);
+ };
+ removeListener = function(el, name, callback) {
+ el.detachEvent('on' + name, callback.$derbyListener);
+ };
+}
View
43 lib/EventDispatcher.js
@@ -0,0 +1,43 @@
+function empty() {}
+
+module.exports = EventDispatcher;
+
+function EventDispatcher(options) {
+ if (options == null) options = {};
+ this._onTrigger = options.onTrigger || empty;
+ this._onBind = options.onBind || empty;
+ this.clear();
+}
+
+EventDispatcher.prototype = {
+ clear: function() {
+ this.names = {};
+ }
+
+, bind: function(name, listener, arg0) {
+ this._onBind(name, listener, arg0);
+ var names = this.names
+ , obj = names[name] || {};
+ obj[JSON.stringify(listener)] = listener;
+ return names[name] = obj;
+ }
+
+, trigger: function(name, value, arg0, arg1, arg2, arg3, arg4, arg5) {
+ var names = this.names
+ , listeners = names[name]
+ , onTrigger = this._onTrigger
+ , count = 0
+ , key, listener;
+ for (key in listeners) {
+ listener = listeners[key];
+ count++;
+ if (false !== onTrigger(name, listener, value, arg0, arg1, arg2, arg3, arg4, arg5)) {
+ continue;
+ }
+ delete listeners[key];
+ count--;
+ }
+ if (!count) delete names[name];
+ return count;
+ }
+}
View
153 lib/PathMap.js
@@ -0,0 +1,153 @@
+module.exports = PathMap
+
+function PathMap() {
+ this.clear();
+}
+PathMap.prototype = {
+ clear: function() {
+ this.count = 0;
+ this.ids = {};
+ this.paths = {};
+ this.arrays = {};
+ }
+
+, id: function(path) {
+ var id;
+ // Return the path for an id, or create a new id and index it
+ return this.ids[path] || (
+ id = ++this.count
+ , this.paths[id] = path
+ , this._indexArray(path, id)
+ , this.ids[path] = id
+ );
+ }
+
+, _indexArray: function(path, id) {
+ var arr, index, match, nested, remainder, set, setArrays;
+ while (match = /^(.+)\.(\d+)(\*?(?:\..+|$))/.exec(path)) {
+ path = match[1];
+ index = +match[2];
+ remainder = match[3];
+ arr = this.arrays[path] || (this.arrays[path] = []);
+ set = arr[index] || (arr[index] = {});
+ if (nested) {
+ setArrays = set.arrays || (set.arrays = {});
+ setArrays[remainder] = true;
+ } else {
+ set[id] = remainder;
+ }
+ nested = true;
+ }
+ }
+
+, _incrItems: function(path, map, start, end, byNum, oldArrays, oldPath) {
+ var arrayMap, arrayPath, arrayPathTo, i, id, ids, itemPath, remainder;
+ if (oldArrays == null) oldArrays = {};
+
+ for (i = start; i < end; i++) {
+ ids = map[i];
+ if (!ids) continue;
+
+ for (id in ids) {
+ remainder = ids[id];
+ if (id === 'arrays') {
+ for (remainder in ids[id]) {
+ arrayPath = (oldPath || path) + '.' + i + remainder;
+ arrayMap = oldArrays[arrayPath] || this.arrays[arrayPath];
+ if (arrayMap) {
+ arrayPathTo = path + '.' + (i + byNum) + remainder;
+ this.arrays[arrayPathTo] = arrayMap;
+ this._incrItems(arrayPathTo, arrayMap, 0, arrayMap.length, 0, oldArrays, arrayPath);
+ }
+ }
+ continue;
+ }
+
+ itemPath = path + '.' + (i + byNum) + remainder;
+ this.paths[id] = itemPath;
+ this.ids[itemPath] = +id;
+ }
+ }
+ }
+
+, _delItems: function(path, map, start, end, len, oldArrays) {
+ var arrayLen, arrayMap, arrayPath, i, id, ids, itemPath, remainder;
+ if (oldArrays == null) oldArrays = {};
+
+ for (i = start; i < len; i++) {
+ ids = map[i];
+ if (!ids) continue;
+
+ for (id in ids) {
+ if (id === 'arrays') {
+ for (remainder in ids[id]) {
+ arrayPath = path + '.' + i + remainder;
+ if (arrayMap = this.arrays[arrayPath]) {
+ arrayLen = arrayMap.length;
+ this._delItems(arrayPath, arrayMap, 0, arrayLen, arrayLen, oldArrays);
+ oldArrays[arrayPath] = arrayMap;
+ delete this.arrays[arrayPath];
+ }
+ }
+ continue;
+ }
+
+ itemPath = this.paths[id];
+ delete this.ids[itemPath];
+ if (i > end) continue;
+ delete this.paths[id];
+ }
+ }
+
+ return oldArrays;
+ }
+
+, onRemove: function(path, start, howMany) {
+ var map = this.arrays[path]
+ , end, len, oldArrays;
+ if (!map) return;
+ end = start + howMany;
+ len = map.length;
+ // Delete indicies for removed items
+ oldArrays = this._delItems(path, map, start, end + 1, len);
+ // Decrement indicies of later items
+ this._incrItems(path, map, end, len, -howMany, oldArrays);
+ map.splice(start, howMany);
+ }
+
+, onInsert: function(path, start, howMany) {
+ var map = this.arrays[path]
+ , end, len, oldArrays;
+ if (!map) return;
+ end = start + howMany;
+ len = map.length;
+ // Delete indicies for items in inserted positions
+ oldArrays = this._delItems(path, map, start, end + 1, len);
+ // Increment indicies of later items
+ this._incrItems(path, map, start, len, howMany, oldArrays);
+ while (howMany--) {
+ map.splice(start, 0, {});
+ }
+ }
+
+, onMove: function(path, from, to, howMany) {
+ var map = this.arrays[path]
+ , afterFrom, afterTo, items, oldArrays;
+ if (!map) return;
+ afterFrom = from + howMany;
+ afterTo = to + howMany;
+ // Adjust paths for items between from and to
+ if (from > to) {
+ oldArrays = this._delItems(path, map, to, afterFrom, afterFrom);
+ this._incrItems(path, map, to, from, howMany, oldArrays);
+ } else {
+ oldArrays = this._delItems(path, map, from, afterTo, afterTo);
+ this._incrItems(path, map, afterFrom, afterTo, -howMany, oldArrays);
+ }
+ // Adjust paths for the moved item(s)
+ this._incrItems(path, map, from, afterFrom, to - from, oldArrays);
+ // Fix the array index
+ items = map.splice(from, howMany);
+ map.splice.apply(map, [to, 0].concat(items));
+ }
+}
View
1,015 lib/View.js
@@ -0,0 +1,1015 @@
+var htmlUtil = require('html-util')
+ , parseHtml = htmlUtil.parse
+ , trimLeading = htmlUtil.trimLeading
+ , unescapeEntities = htmlUtil.unescapeEntities
+ , escapeHtml = htmlUtil.escapeHtml
+ , escapeAttribute = htmlUtil.escapeAttribute
+ , isVoid = htmlUtil.isVoid
+ , conditionalComment = htmlUtil.conditionalComment
+ , markup = require('./markup')
+ , viewPath = require('./viewPath')
+ , wrapRemainder = viewPath.wrapRemainder
+ , ctxPath = viewPath.ctxPath
+ , extractPlaceholder = viewPath.extractPlaceholder
+ , dataValue = viewPath.dataValue
+ , pathFnArgs = viewPath.pathFnArgs;
+
+module.exports = View;
+
+function empty() {
+ return '';
+}
+
+function notFound(name, ns) {
+ if (ns) name = ns + ':' + name;
+ throw new Error("Can't find view: " + name);
+}
+
+var defaultCtx = {
+ $depth: 0
+, $aliases: {}
+, $paths: []
+, $indices: []
+};
+
+var defaultGetFns = {
+ equal: function(a, b) {
+ return a === b;
+ }
+, not: function(value) {
+ return !value;
+ }
+};
+
+var defaultSetFns = {
+ equal: function(value, a) {
+ return value ? [a] : [];
+ }
+, not: function(value) {
+ return [!value];
+ }
+};
+
+function View() {
+ this.clear();
+ this.getFns = Object.create(defaultGetFns);
+ this.setFns = Object.create(defaultSetFns);
+ this._componentNamespaces = {app: true};
+ this._nonvoidComponents = {};
+}
+
+View.prototype = {
+ clear: function() {
+ this._views = Object.create(this.defaultViews);
+ this._made = {};
+ this._renders = {};
+ this._inline = '';
+ return this._idCount = 0;
+ }
+
+ // All automatically created ids start with a dollar sign
+, _uniqueId: function() {
+ return '$' + (this._idCount++).toString(36);
+ }
+
+, defaultViews: {
+ doctype: function() {
+ return '<!DOCTYPE html>';
+ }
+ , root: empty
+ , charset: function() {
+ return '<meta charset=utf-8>';
+ }
+ , title$s: empty
+ , head: empty
+ , header: empty
+ , body: empty
+ , footer: empty
+ , scripts: empty
+ , tail: empty
+ }
+
+, make: function(name, template, options, templatePath, boundMacro) {
+ var view = this
+ , isString, onBind, renderer, render;
+ // Cache any templates that are made so that they can be
+ // re-parsed with different items bound when using macros
+ this._made[name] = [template, options, templatePath];
+ if (options && 'nonvoid' in options) {
+ this._nonvoidComponents[name] = true;
+ }
+
+ if (templatePath && (render = this._renders[templatePath])) {
+ this._views[name] = render;
+ return
+ }
+
+ name = name.toLowerCase();
+ if (name === 'title') {
+ this.make('title$s', template, options, templatePath);
+ } else if (name === 'title$s') {
+ isString = true;
+ onBind = function(events, name) {
+ var macro = false;
+ return bindEvents(events, macro, name, render, ['$_doc', 'prop', 'title']);
+ }
+ }
+
+ renderer = function(ctx) {
+ renderer = parse(view, name, template, isString, onBind, boundMacro);
+ return renderer(ctx);
+ }
+ render = function(ctx) {
+ return renderer(ctx);
+ }
+
+ this._views[name] = render;
+ if (templatePath) this._renders[templatePath] = render;
+ }
+
+, _makeAll: function(templates, instances) {
+ var name, instance, options, templatePath;
+ for (name in instances) {
+ instance = instances[name];
+ templatePath = instance[0];
+ options = instance[1];
+ this.make(name, templates[templatePath], options, templatePath);
+ }
+ }
+
+, _findItem: function(name, ns, prop) {
+ var items = this[prop]
+ , item, last, i, segments, testNs;
+ if (ns) {
+ ns = ns.toLowerCase();
+ item = items[ns + ':' + name];
+ if (item) return item;
+
+ segments = ns.split(':');
+ last = segments.length - 1;
+ if (last > 0) {
+ for (i = last; i--;) {
+ testNs = segments.slice(0, i).join(':');
+ item = items[testNs + ':' + name];
+ if (item) return item;
+ }
+ }
+ }
+ return items[name];
+ }
+
+, _find: function(name, ns, boundMacro) {
+ var hash, hashedName, out, item, template, options, templatePath;
+ if (boundMacro && (hash = keyHash(boundMacro))) {
+ hash = '$b:' + hash;
+ hashedName = name + hash;
+ out = this._findItem(hashedName, ns, '_views');
+ if (out) return out;
+
+ item = this._findItem(name, ns, '_made') || notFound(name, ns);
+ template = item[0];
+ options = item[1];
+ templatePath = item[2] + hash;
+ this.make(hashedName, template, options, templatePath, boundMacro);
+ return this._find(hashedName, ns);
+ }
+ return this._findItem(name, ns, '_views') || notFound(name, ns);
+ }
+
+, get: function(name, ns, ctx) {
+ if (typeof ns === 'object') {
+ ctx = ns;
+ ns = '';
+ }
+ ctx = ctx ? extend(ctx, defaultCtx) : Object.create(defaultCtx);
+ return this._find(name, ns)(ctx);
+ }
+
+, inline: empty
+
+, fn: function(name, fn) {
+ var get, set;
+ if (typeof fn === 'object') {
+ get = fn.get;
+ set = fn.set;
+ } else {
+ get = fn;
+ }
+ this.getFns[name] = get;
+ if (set) this.setFns[name] = set;
+ }
+
+, render: function(model, ns, ctx, silent) {
+ if (typeof ns === 'object') {
+ silent = ctx;
+ ctx = ns;
+ ns = '';
+ }
+ this.model = model;
+ this._idCount = 0;
+ this.model.__pathMap.clear();
+ this.model.__events.clear();
+ this.model.__blockPaths = {};
+ this.dom.clear();
+
+ var title = this.get('title$s', ns, ctx)
+ , rootHtml = this.get('root', ns, ctx)
+ , bodyHtml = this.get('header', ns, ctx) +
+ this.get('body', ns, ctx) +
+ this.get('footer', ns, ctx);
+ if (silent) return;
+
+ var doc = document
+ , documentElement = doc.documentElement
+ , attrs = documentElement.attributes
+ , i, attr, fakeRoot, body;
+
+ // Remove all current attributes on the documentElement and replace
+ // them with the attributes in the rendered rootHtml
+ for (i = attrs.length; i--;) {
+ attr = attrs[i];
+ documentElement.removeAttribute(attr.name);
+ }
+ // Using the DOM to get the attributes on an <html> tag would require
+ // some sort of iframe hack until DOMParser has better browser support.
+ // String parsing the html should be simpler and more efficient
+ parseHtml(rootHtml, {
+ start: function(tag, tagName, attrs) {
+ if (tagName !== 'html') return;
+ for (var attr in attrs) {
+ documentElement.setAttribute(attr, attrs[attr]);
+ }
+ }
+ });
+
+ fakeRoot = doc.createElement('html');
+ fakeRoot.innerHTML = bodyHtml;
+ body = fakeRoot.getElementsByTagName('body')[0];
+ documentElement.replaceChild(body, doc.body);
+ doc.title = title;
+ }
+
+, escapeHtml: escapeHtml
+, escapeAttribute: escapeAttribute
+}
+
+function keyHash(obj) {
+ var keys = []
+ , key;
+ for (key in obj) {
+ keys.push(key);
+ }
+ return keys.sort().join(',');
+}
+
+function extend(parent, obj) {
+ var out = Object.create(parent)
+ , key;
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
+ return out;
+ }
+ for (key in obj) {
+ out[key] = obj[key];
+ }
+ return out;
+}
+
+function modelListener(params, triggerId, blockPaths, pathId, partial, ctx) {
+ var listener = typeof params === 'function'
+ ? params(triggerId, blockPaths, pathId)
+ : params;
+ listener.partial = partial;
+ listener.ctx = ctx.$stringCtx || ctx;
+ return listener;
+}
+
+function bindEvents(events, macro, name, partial, params) {
+ if (~name.indexOf('(')) {
+ var args = pathFnArgs(name);
+ if (!args.length) return;
+ events.push(function(ctx, modelEvents, dom, pathMap, view, blockPaths, triggerId) {
+ var listener = modelListener(params, triggerId, blockPaths, null, partial, ctx)
+ , path, pathId, i;
+ listener.getValue = function(model, triggerPath) {
+ patchCtx(ctx, triggerPath);
+ return dataValue(view, ctx, model, name, macro);
+ }
+ for (i = args.length; i--;) {
+ path = ctxPath(ctx, args[i], macro);
+ pathId = pathMap.id(path + '*');
+ modelEvents.bind(pathId, listener);
+ }
+ });
+ return;
+ }
+
+ var match = /(\.*)(.*)/.exec(name)
+ , prefix = match[1] || ''
+ , relativeName = match[2] || ''
+ , segments = relativeName.split('.')
+ , bindName, i;
+ for (i = segments.length; i--;) {
+ bindName = prefix + segments.slice(0, i).join('.');
+ (function(bindName) {
+ events.push(function(ctx, modelEvents, dom, pathMap, view, blockPaths, triggerId) {
+ var path = ctxPath(ctx, name, macro)
+ , listener, pathId;
+ if (!path) return;
+ pathId = pathMap.id(path);
+ listener = modelListener(params, triggerId, blockPaths, pathId, partial, ctx);
+ if (name !== bindName) {
+ path = ctxPath(ctx, bindName, macro);
+ pathId = pathMap.id(path);
+ listener.getValue = function(model, triggerPath) {
+ patchCtx(ctx, triggerPath);
+ return dataValue(view, ctx, model, name, macro);
+ }
+ }
+ modelEvents.bind(pathId, listener);
+ });
+ })(bindName);
+ }
+}
+
+function bindEventsById(events, macro, name, partial, attrs, method, prop, isBlock) {
+ function params(triggerId, blockPaths, pathId) {
+ var id = attrs._id || attrs.id;
+ if (isBlock && pathId) blockPaths[id] = pathId;
+ return [id, method, prop];
+ }
+ bindEvents(events, macro, name, partial, params);
+}
+
+function bindEventsByIdString(events, macro, name, partial, attrs, method, prop) {
+ function params(triggerId) {
+ var id = triggerId || attrs._id || attrs.id;
+ return [id, method, prop];
+ }
+ bindEvents(events, macro, name, partial, params);
+}
+
+function addId(view, attrs) {
+ if (attrs.id == null) {
+ attrs.id = function() {
+ return attrs._id = view._uniqueId();
+ };
+ }
+}
+
+function reduceStack(stack) {
+ var html = ['']
+ , i = 0
+ , attrs, bool, item, key, value, j, len;
+
+ function pushValue(value, isAttr) {
+ if (value && value.call) {
+ return i = html.push(value, '') - 1;
+ } else {
+ return html[i] += isAttr ? escapeAttribute(value) : value;
+ }
+ }
+
+ for (j = 0, len = stack.length; j < len; j++) {
+ item = stack[j];
+ switch (item[0]) {
+ case 'start':
+ html[i] += '<' + item[1];
+ attrs = item[2];
+ // Make sure that the id attribute is rendered first
+ if ('id' in attrs) {
+ html[i] += ' id=';
+ pushValue(attrs.id, true);
+ }
+ for (key in attrs) {
+ if (key === 'id') continue;
+ value = attrs[key];
+ if (value != null) {
+ if (bool = value.bool) {
+ pushValue(bool);
+ continue;
+ }
+ html[i] += ' ' + key + '=';
+ pushValue(value, true);
+ } else {
+ html[i] += ' ' + key;
+ }
+ }
+ html[i] += '>';
+ break;
+ case 'text':
+ pushValue(item[1]);
+ break;
+ case 'end':
+ html[i] += '</' + item[1] + '>';
+ break;
+ case 'marker':
+ html[i] += '<!--' + item[1];
+ pushValue(item[2].id);
+ html[i] += '-->';
+ }
+ }
+ return html;
+}
+
+function patchCtx(ctx, triggerPath) {
+ var path = ctx.$paths[0];
+ if (!(triggerPath && path)) return;
+
+ var segments = path.split('.')
+ , triggerSegments = triggerPath.replace(/\*$/, '').split('.')
+ , indices = ctx.$indices.slice()
+ , index = indices.length
+ , i, len, segment, triggerSegment, n;
+ for (i = 0, len = segments.length; i < len; i++) {
+ segment = segments[i];
+ triggerSegment = triggerSegments[i];
+ // `(n = +triggerSegment) === n` will be false only if segment is NaN
+ if (segment === '$#' && (n = +triggerSegment) === n) {
+ indices[--index] = n;
+ } else if (segment !== triggerSegment) {
+ break;
+ }
+ }
+ ctx.$indices = indices;
+}
+
+function renderer(view, items, events, onRender) {
+ return function(ctx, model, triggerPath, triggerId) {
+ patchCtx(ctx, triggerPath);
+
+ if (!model) model = view.model; // Needed, since model parameter is optional
+ var pathMap = model.__pathMap
+ , modelEvents = model.__events
+ , blockPaths = model.__blockPaths
+ , dom = view.dom
+ , html = ''
+ , i, len, item, event;
+
+ if (onRender) ctx = onRender(ctx);
+ for (i = 0, len = items.length; i < len; i++) {
+ item = items[i];
+ html += typeof item === 'function' ? item(ctx, model) || '' : item;
+ }
+ for (i = 0, len = events.length; i < len; i++) {
+ events[i](ctx, modelEvents, dom, pathMap, view, blockPaths, triggerId);
+ }
+ return html;
+ }
+}
+
+function extendCtx(ctx, value, name, alias, index, isArray) {
+ var path = ctxPath(ctx, name, null, true)
+ , aliases;
+ ctx = extend(ctx, value);
+ ctx["this"] = value;
+ if (alias) {
+ aliases = ctx.$aliases = Object.create(ctx.$aliases);
+ aliases[alias] = ctx.$depth;
+ }
+ if (path) ctx.$paths = [path].concat(ctx.$paths);
+ if (name) ctx.$depth++;
+ if (index != null) {
+ ctx.$indices = [index].concat(ctx.$indices);
+ isArray = true;
+ }
+ if (isArray && ctx.$paths[0]) {
+ ctx.$paths[0] += '.$#';
+ }
+ return ctx;
+}
+
+function partialValue(view, ctx, model, name, value, listener, macro) {
+ if (listener) return value;
+ return name ? dataValue(view, ctx, model, name, macro) : true;
+}
+
+function partialFn(view, name, type, alias, render, macroCtx, macro) {
+ function conditionalRender(ctx, model, triggerPath, value, index, condition) {
+ if (condition) {
+ var renderCtx = extendCtx(ctx, value, name, alias, index);
+ return render(renderCtx, model, triggerPath);
+ }
+ return '';
+ }
+
+ function withFn(ctx, model, triggerPath, triggerId, value, index, listener) {
+ value = partialValue(view, ctx, model, name, value, listener, macro);
+ return conditionalRender(ctx, model, triggerPath, value, index, true);
+ }
+
+ if (type === 'partial') {
+ return function(ctx, model, triggerPath, triggerId, value, index, listener) {
+ var renderCtx = Object.create(ctx)
+ , parentMacroCtx = ctx.$macroCtx;
+ renderCtx.$macroCtx = parentMacroCtx ? extend(parentMacroCtx, macroCtx) : macroCtx;
+ return render(renderCtx, model, triggerPath);
+ }
+ }
+
+ if (type === 'with' || type === 'else') {
+ return withFn;
+ }
+
+ if (type === 'if' || type === 'else if') {
+ return function(ctx, model, triggerPath, triggerId, value, index, listener) {
+ value = partialValue(view, ctx, model, name, value, listener, macro);
+ var condition = !!(Array.isArray(value) ? value.length : value);
+ return conditionalRender(ctx, model, triggerPath, value, index, condition);
+ }
+ }
+
+ if (type === 'unless') {
+ return function(ctx, model, triggerPath, triggerId, value, index, listener) {
+ value = partialValue(view, ctx, model, name, value, listener, macro);
+ var condition = !(Array.isArray(value) ? value.length : value);
+ return conditionalRender(ctx, model, triggerPath, value, index, condition);
+ }
+ }
+
+ if (type === 'each') {
+ return function(ctx, model, triggerPath, triggerId, value, index, listener) {
+ var indices, isArray, item, out, renderCtx, i, len;
+ value = partialValue(view, ctx, model, name, value, listener, macro);
+ isArray = Array.isArray(value);
+
+ if (listener && !isArray) {
+ return withFn(ctx, model, triggerPath, triggerId, value, index, true);
+ }
+
+ if (!isArray) return '';
+
+ ctx = extendCtx(ctx, null, name, alias, null, true);
+
+ out = '';
+ indices = ctx.$indices;
+ for (i = 0, len = value.length; i < len; i++) {
+ item = value[i];
+ renderCtx = extend(ctx, item);
+ renderCtx["this"] = item;
+ renderCtx.$indices = [i].concat(indices);
+ out += render(renderCtx, model, triggerPath);
+ }
+ return out;
+ }
+ }
+
+ throw new Error('Unknown block type: ' + type);
+}
+
+var objectToString = Object.prototype.toString;
+
+function textFn(view, name, escape, macro) {
+ return function(ctx, model) {
+ var value = dataValue(view, ctx, model, name, macro)
+ , text = typeof value === 'string' ? value
+ : value == null ? ''
+ : value.toString === objectToString ? JSON.stringify(value)
+ : value.toString();
+ return escape ? escape(text) : text;
+ }
+}
+
+function sectionFn(view, queue) {
+ var render = renderer(view, reduceStack(queue.stack), queue.events)
+ , block = queue.block;
+ return partialFn(view, block.name, block.type, block.alias, render, null, block.macro);
+}
+
+function blockFn(view, sections) {
+ var len = sections.length;
+ if (!len) return;
+ if (len === 1) {
+ return sectionFn(view, sections[0]);
+
+ } else {
+ var fns = []
+ , i;
+ for (i = 0; i < len; i++) {
+ fns.push(sectionFn(view, sections[i]));
+ }
+ return function(ctx, model, triggerPath, triggerId, value, index, listener) {
+ var out, fn;
+ for (i = 0; i < len; i++) {
+ fn = fns[i];
+ out = fn(ctx, model, triggerPath, triggerId, value, index, listener);
+ if (out) return out;
+ }
+ return '';
+ }
+ }
+}
+
+function parseMarkup(type, attr, tagName, events, attrs, name) {
+ var parser = markup[type][attr]
+ , anyOut, anyParser, elOut, elParser, out;
+ if (!parser) return;
+ if (anyParser = parser['*']) {
+ anyOut = anyParser(events, attrs, name);
+ }
+ if (elParser = parser[tagName]) {
+ elOut = elParser(events, attrs, name);
+ }
+ out = anyOut ? extend(anyOut, elOut) : elOut;
+ if (out && out.del) delete attrs[attr];
+ return out;
+}
+
+function pushText(stack, text) {
+ if (text) stack.push(['text', text]);
+}
+
+function pushVarFn(view, stack, fn, name, escapeFn, macro) {
+ if (fn) {
+ pushText(stack, fn);
+ } else {
+ pushText(stack, textFn(view, name, escapeFn, macro));
+ }
+}
+
+function boundMacroName(boundMacro, name) {
+ var macroVar = name.split('.')[0];
+ return boundMacro[macroVar];
+}
+
+function isBound(boundMacro, match, name) {
+ if (!(name && match.macro)) return match.bound;
+ if (~name.indexOf('(')) {
+ var args = pathFnArgs(name)
+ , i, len;
+ for (i = 0, len = args.length; i < len; i++) {
+ if (boundMacroName(boundMacro, args[i])) return true;
+ }
+ return false;
+ }
+ return boundMacroName(boundMacro, name);
+}
+
+function pushVar(view, ns, stack, events, boundMacro, remainder, match, fn) {
+ var name = match.name
+ , partial = match.partial
+ , macro = match.macro
+ , escapeFn = match.escaped && escapeHtml
+ , attr, attrs, boundOut, last, tagName, wrap, render;
+
+ if (partial) {
+ render = view._find(partial, ns, boundMacro);
+ fn = partialFn(view, name, 'partial', match.alias, render, match.macroCtx);
+ }
+
+ if (isBound(boundMacro, match, name)) {
+ last = stack[stack.length - 1];
+ wrap = match.pre ||
+ !last ||
+ (last[0] !== 'start') ||
+ isVoid(tagName = last[1]) ||
+ wrapRemainder(tagName, remainder);
+
+ if (wrap) {
+ stack.push(['marker', '', attrs = {}]);
+ } else {
+ attrs = last[2];
+ for (attr in attrs) {
+ parseMarkup('boundParent', attr, tagName, events, attrs, name);
+ }
+ boundOut = parseMarkup('boundParent', '*', tagName, events, attrs, name);
+ if (boundOut) {
+ bindEventsById(events, macro, name, null, attrs, boundOut.method, boundOut.property);
+ }
+ }
+ addId(view, attrs);
+
+ if (!boundOut) {
+ bindEventsById(events, macro, name, fn, attrs, 'html', !fn && escapeFn, true);
+ }
+ }
+
+ pushVarFn(view, stack, fn, name, escapeFn, macro);
+ if (wrap) {
+ stack.push([
+ 'marker'
+ , '$'
+ , { id: function() { return attrs._id } }
+ ]);
+ }
+}
+
+function pushVarString(view, ns, stack, events, boundMacro, remainder, match, fn) {
+ var name = match.name
+ , escapeFn = !match.escaped && unescapeEntities;
+ function bindOnce(ctx) {
+ ctx.$onBind(events, name);
+ bindOnce = empty;
+ }
+ if (isBound(boundMacro, match, name)) {
+ events.push(function(ctx) {
+ bindOnce(ctx);
+ });
+ }
+ pushVarFn(view, stack, fn, name, escapeFn, match.macro);
+}
+
+function parseMatchError(text, message) {
+ throw new Error(message + '\n\n' + text + '\n');
+}
+
+function onBlock(start, end, block, queues, callbacks) {
+ var boundMacro, lastQueue, queue;
+ if (end) {
+ lastQueue = queues.pop();
+ queue = queues.last();
+ queue.sections.push(lastQueue);
+ } else {
+ queue = queues.last();
+ }
+
+ if (start) {
+ boundMacro = Object.create(queue.boundMacro);
+ queues.push(queue = {
+ stack: []
+ , events: []
+ , block: block
+ , sections: []
+ , boundMacro: boundMacro
+ });
+ callbacks.onStart(queue);
+ } else {
+ if (end) {
+ callbacks.onStart(queue);
+ callbacks.onEnd(queue.sections);
+ queue.sections = [];
+ } else {
+ callbacks.onContent(block);
+ }
+ }
+}
+
+function parseMatch(text, match, queues, callbacks) {
+ var hash = match.hash
+ , type = match.type
+ , name = match.name
+ , block = queues.last().block
+ , blockType = block && block.type
+ , startBlock, endBlock;
+
+ if (type === 'if' || type === 'unless' || type === 'each' || type === 'with') {
+ if (hash === '#') {
+ startBlock = true;
+ } else if (hash === '/') {
+ endBlock = true;
+ } else {
+ parseMatchError(text, type + ' blocks must begin with a #');
+ }
+
+ } else if (type === 'else' || type === 'else if') {
+ if (hash) {
+ parseMatchError(text, type + ' blocks may not start with ' + hash);
+ }
+ if (blockType !== 'if' && blockType !== 'else if' &&
+ blockType !== 'unless' && blockType !== 'each') {
+ parseMatchError(text, type + ' may only follow `if`, `else if`, `unless`, or `each`');
+ }
+ startBlock = true;
+ endBlock = true;
+
+ } else if (hash === '/') {
+ endBlock = true;
+
+ } else if (hash === '#') {
+ parseMatchError(text, '# must be followed by `if`, `unless`, `each`, or `with`');
+ }
+
+ if (endBlock && !block) {
+ parseMatchError(text, 'Unmatched template end tag');
+ }
+
+ onBlock(startBlock, endBlock, match, queues, callbacks);
+}
+
+function parseAttr(view, viewName, events, boundMacro, tagName, attrs, attr, value) {
+ if (typeof value === 'function') return;
+
+ var attrOut = parseMarkup('attr', attr, tagName, events, attrs, value) || {}
+ , boundOut, macro, match, name, render, method, property;
+ if (attrOut.addId) addId(view, attrs);
+
+ if (match = extractPlaceholder(value)) {
+ name = match.name, macro = match.macro;
+
+ if (match.pre || match.post) {
+ // Attributes must be a single string, so create a string partial
+ addId(view, attrs);
+ render = parse(view, viewName, value, true, function(events, name) {
+ bindEventsByIdString(events, macro, name, render, attrs, 'attr', attr);
+ }, boundMacro);
+
+ attrs[attr] = attr === 'id' ? function(ctx, model) {
+ return attrs._id = escapeAttribute(render(ctx, model));
+ } : function(ctx, model) {
+ return escapeAttribute(render(ctx, model));
+ }
+ return;
+ }
+
+ if (isBound(boundMacro, match, name)) {
+ boundOut = parseMarkup('bound', attr, tagName, events, attrs, name) || {};
+ addId(view, attrs);
+ method = boundOut.method || 'attr';
+ property = boundOut.property || attr;
+ bindEventsById(events, macro, name, null, attrs, method, property);
+ }
+
+ if (!attrOut.del) {
+ macro = match.macro;
+ attrs[attr] = attrOut.bool ? {
+ bool: function(ctx, model) {
+ return (dataValue(view, ctx, model, name, macro)) ? ' ' + attr : '';
+ }
+ } : textFn(view, name, escapeAttribute, macro);
+ }
+ }
+}
+
+function parsePartialAttr(view, viewName, events, attrs, attr, value) {
+ var bound = false
+ , match = extractPlaceholder(value)
+ , name;
+ if (attr === 'content') {
+ throw new Error('components may not have an attribute named "content"');
+ }
+
+ if (match) {
+ if (match.pre || match.post) {
+ throw new Error('unimplemented: blocks in component attributes');
+ }
+
+ name = match.name;
+ bound = match.bound;
+ attrs[attr] = {$macroVar: name};
+
+ } else if (value === 'true') {
+ attrs[attr] = true;
+ } else if (value === 'false') {
+ attrs[attr] = false;
+ } else if (value === 'null') {
+ attrs[attr] = null;
+ } else if (!isNaN(value)) {
+ attrs[attr] = +value;
+ }
+
+ return bound;
+}
+
+function partialName(view, tagName) {
+ var i = tagName.indexOf(':')
+ , partial, tagNs
+ if (!~i) return;
+ tagNs = tagName.slice(0, i);
+ if (!view._componentNamespaces[tagNs]) return;
+ return partial = tagName.slice(i + 1);
+}
+
+function parse(view, viewName, template, isString, onBind, boundMacro) {
+ if (boundMacro == null) boundMacro = {};
+ var queues, stack, events, onRender, push;
+
+ queues = [{
+ stack: stack = []
+ , events: events = []
+ , sections: []
+ , boundMacro: boundMacro
+ }];
+ queues.last = function() {
+ return queues[queues.length - 1];
+ };
+
+ function onStart(queue) {
+ stack = queue.stack;
+ events = queue.events;
+ boundMacro = queue.boundMacro;
+ }
+
+ if (isString) {
+ push = pushVarString;
+ onRender = function(ctx) {
+ if (ctx.$stringCtx) return ctx;
+ ctx = Object.create(ctx);
+ ctx.$onBind = onBind;
+ ctx.$stringCtx = ctx;
+ return ctx;
+ }
+ } else {
+ push = pushVar;
+ }
+
+ var index = viewName.lastIndexOf(':')
+ , ns = ~index ? viewName.slice(0, index) : ''
+ , minifyContent = true;
+
+ function parseStart(tag, tagName, attrs) {
+ var attr, block, bound, isNonvoid, out, parser, partial, value
+ if ('x-no-minify' in attrs) {
+ delete attrs['x-no-minify'];
+ minifyContent = false;
+ } else {
+ minifyContent = true;
+ }
+
+ if (partial = partialName(view, tagName)) {
+ isNonvoid = view._findItem(partial, ns, '_nonvoidComponents');
+ for (attr in attrs) {
+ value = attrs[attr];
+ bound = parsePartialAttr(view, viewName, events, attrs, attr, value);
+ if (bound) boundMacro[attr] = true;
+ }
+
+ block = {
+ partial: partial
+ , macroCtx: attrs
+ };
+ if (isNonvoid) {
+ onBlock(true, false, block, queues, {onStart: onStart});
+ } else {
+ push(view, ns, stack, events, boundMacro, '', block);
+ }
+ return;
+ }
+
+ if (parser = markup.element[tagName]) {
+ out = parser(events, attrs);
+ if (out != null ? out.addId : void 0) {
+ addId(view, attrs);
+ }
+ }
+
+ for (attr in attrs) {
+ value = attrs[attr];
+ parseAttr(view, viewName, events, boundMacro, tagName, attrs, attr, value);
+ }
+ stack.push(['start', tagName, attrs]);
+ }
+
+ function parseText(text, isRawText, remainder) {
+ var match = extractPlaceholder(text)
+ , post, pre;
+ if (!match || isRawText) {
+ if (minifyContent) {
+ text = isString ? unescapeEntities(trimLeading(text)) : trimLeading(text);
+ }
+ pushText(stack, text);
+ return;
+ }
+
+ pre = match.pre;
+ post = match.post;
+ if (isString) pre = unescapeEntities(pre);
+ pushText(stack, pre);
+ remainder = post || remainder;
+
+ parseMatch(text, match, queues, {
+ onStart: onStart
+ , onEnd: function(sections) {
+ var fn = blockFn(view, sections);
+ push(view, ns, stack, events, boundMacro, remainder, sections[0].block, fn);
+ }
+ , onContent: function(match) {
+ push(view, ns, stack, events, boundMacro, remainder, match);
+ }
+ });
+
+ if (post) return parseText(post);
+ }
+
+ function parseEnd(tag, tagName) {
+ var partial = partialName(view, tagName);
+ if (partial) {
+ onBlock(false, true, null, queues, {
+ onStart: onStart
+ , onEnd: function(queues) {
+ var queue = queues[0]
+ , block = queue.block;
+ block.macroCtx.content = renderer(view, reduceStack(queue.stack), queue.events);
+ push(view, ns, stack, events, boundMacro, '', block);
+ }
+ })
+ return;
+ }
+ stack.push(['end', tagName]);
+ }
+
+ if (isString) {
+ parseText(template);
+ } else {
+ parseHtml(template, {
+ start: parseStart
+ , text: parseText
+ , end: parseEnd
+ , comment: function(tag) {
+ if (conditionalComment(tag)) pushText(stack, tag);
+ }
+ , other: function(tag) {
+ pushText(stack, tag);
+ }
+ });
+ }
+ return renderer(view, reduceStack(stack), events, onRender);
+}
View
255 lib/View.server.js
@@ -0,0 +1,255 @@
+var EventDispatcher = require('./EventDispatcher')
+ , racer = require('racer')
+ , Promise = racer.util.Promise
+ , isProduction = racer.util.isProduction
+ , Model = racer["protected"].Model
+ , uglify = require('racer/node_modules/uglify-js')
+ , files = require('./files')
+ , htmlUtil = require('html-util')
+ , escapeHtml = htmlUtil.escapeHtml
+ , trimLeading = htmlUtil.trimLeading
+ , refresh = require('./refresh.server')
+ , errorHtml = refresh.errorHtml
+ , cssError = refresh.cssError
+ , templateError = refresh.templateError
+ , View = module.exports = require('./View')
+
+ , emptyRes = {
+ getHeader: empty
+ , setHeader: empty
+ , write: empty
+ , end: empty
+ }
+ , emptyPathMap = {
+ id: empty
+ }
+ , emptyModel = {
+ get: empty
+ , bundle: empty
+ , __pathMap: emptyPathMap
+ }
+ , emptyEventDispatcher = {
+ bind: empty
+ }
+ , emptyDom = {
+ bind: empty
+ }
+
+function empty() {}
+
+function escapeInlineScript(s) {
+ return s.replace(/<\//g, '<\\/');
+}
+
+function loadTemplatesScript(requirePath, templates, instances) {
+ return "(function() {\n require('" + requirePath + "').view._makeAll(\n " +
+ (JSON.stringify(templates, null, 2)) + ", " +
+ (JSON.stringify(instances, null, 2)) + "\n );\n})();";
+}
+
+View.prototype.inline = function(fn) {
+ return this._inline += uglify("(" + fn + ")()") + ';';