batman.coffee | |
---|---|
batman.js Created by Nick Small Copyright 2011, Shopify | |
The global namespace, the | Batman = (mixins...) ->
new Batman.Object mixins... |
Global Helpers | |
| Batman.typeOf = $typeOf = (object) ->
_objectToString.call(object).slice(8, -1) |
Cache this function to skip property lookups. | _objectToString = Object.prototype.toString |
| Batman.mixin = $mixin = (to, mixins...) ->
hasSet = typeof to.set is 'function'
for mixin in mixins
continue if $typeOf(mixin) isnt 'Object'
for own key, value of mixin
continue if key in ['initialize', 'uninitialize', 'prototype']
if hasSet
to.set(key, value)
else
to[key] = value
if typeof mixin.initialize is 'function'
mixin.initialize.call to
to |
| Batman.unmixin = $unmixin = (from, mixins...) ->
for mixin in mixins
for key of mixin
continue if key in ['initialize', 'uninitialize']
delete from[key]
if typeof mixin.uninitialize is 'function'
mixin.uninitialize.call from
from |
We can use $block to make it accept the callback in both ways:
or | Batman._block = $block = (lengthOrFunction, fn) ->
if fn?
argsLength = lengthOrFunction
else
fn = lengthOrFunction
callbackEater = (args...) ->
ctx = @
f = (callback) ->
args.push callback
fn.apply(ctx, args) |
Call the function right now if we've been passed the callback already or if we've reached the argument count threshold | if (typeof args[args.length-1] is 'function') || (argsLength && (args.length >= argsLength))
f(args.pop())
else
f |
| Batman._findName = $findName = (f, context) ->
unless f.displayName
for key, value of context
if value is f
f.displayName = key
break
f.displayName |
Properties | class Batman.Property
@defaultAccessor:
get: (key) -> @[key]
set: (key, val) -> @[key] = val
unset: (key) -> x = @[key]; delete @[key]; x
@triggerTracker: null
@forBaseAndKey: (base, key) ->
if base._batman
Batman.initializeObject base
properties = base._batman.properties ||= new Batman.SimpleHash
properties.get(key) or properties.set(key, new @(base, key))
else
new @(base, key)
constructor: (@base, @key) ->
isProperty: true
accessor: ->
accessors = @base._batman?.get('keyAccessors')
if accessors && (val = accessors.get(@key))
return val
else
@base._batman?.getFirst('defaultAccessor') or Batman.Property.defaultAccessor
registerAsTrigger: ->
tracker.add @ if tracker = Batman.Property.triggerTracker
getValue: ->
@registerAsTrigger()
@accessor()?.get.call @base, @key
setValue: (val) ->
@accessor()?.set.call @base, @key, val
unsetValue: -> @accessor()?.unset.call @base, @key
isEqual: (other) ->
@constructor is other.constructor and @base is other.base and @key is other.key
class Batman.ObservableProperty extends Batman.Property
constructor: (base, key) ->
super
@observers = new Batman.SimpleSet
@refreshTriggers() if @hasObserversToFire()
@_preventCount = 0
setValue: (val) ->
@cacheDependentValues()
super
@fireDependents()
val
unsetValue: ->
@cacheDependentValues()
super
@fireDependents()
return
cacheDependentValues: ->
if @dependents
@dependents.forEach (prop) -> prop.cachedValue = prop.getValue()
fireDependents: ->
if @dependents
@dependents.forEach (prop) ->
prop.fire(prop.getValue(), prop.cachedValue) if prop.hasObserversToFire?()
observe: (fireImmediately..., callback) ->
fireImmediately = fireImmediately[0] is true
currentValue = @getValue()
@observers.add callback
@refreshTriggers()
callback.call(@base, currentValue, currentValue) if fireImmediately
@
hasObserversToFire: ->
return true if @observers.length > 0
if @base._batman
@base._batman.ancestors().some((ancestor) => ancestor.property?(@key)?.observers?.length > 0)
else
false
prevent: -> @_preventCount++
allow: -> @_preventCount-- if @_preventCount > 0
isAllowedToFire: -> @_preventCount <= 0
fire: (args...) ->
return unless @isAllowedToFire() and @hasObserversToFire()
key = @key
base = @base
observerSets = [@observers]
@observers.forEach (callback) ->
callback?.apply base, args
if @base._batman
@base._batman.ancestors (ancestor) ->
ancestor.property?(key).observers.forEach (callback) ->
callback?.apply base, args
@refreshTriggers()
forget: (observer) ->
if observer
@observers.remove(observer)
else
@observers = new Batman.SimpleSet
@clearTriggers() unless @hasObserversToFire()
refreshTriggers: ->
Batman.Property.triggerTracker = new Batman.SimpleSet
@getValue()
if @triggers
@triggers.forEach (property) =>
unless Batman.Property.triggerTracker.has(property)
property.dependents?.remove @
@triggers = Batman.Property.triggerTracker
@triggers.forEach (property) =>
property.dependents ||= new Batman.SimpleSet
property.dependents.add @
delete Batman.Property.triggerTracker
clearTriggers: ->
@triggers.forEach (property) =>
property.dependents.remove @
@triggers = new Batman.SimpleSet |
Keypaths | class Batman.Keypath extends Batman.ObservableProperty
constructor: (base, key) ->
if $typeOf(key) is 'String'
@segments = key.split('.')
@depth = @segments.length
else
@segments = [key]
@depth = 1
super
slice: (begin, end = @depth) ->
base = @base
for segment in @segments.slice(0, begin)
return unless base? and base = Batman.Keypath.forBaseAndKey(base, segment).getValue()
Batman.Keypath.forBaseAndKey base, @segments.slice(begin, end).join('.')
terminalProperty: -> @slice -1
getValue: ->
@registerAsTrigger()
if @depth is 1 then super else @terminalProperty()?.getValue()
setValue: (val) -> if @depth is 1 then super else @terminalProperty()?.setValue(val)
unsetValue: -> if @depth is 1 then super else @terminalProperty()?.unsetValue() |
Observable | |
Batman.Observable is a generic mixin that can be applied to any object to allow it to be bound to.
It is applied by default to every instance of | Batman.Observable =
isObservable: true
property: (key) ->
Batman.initializeObject @
Batman.Keypath.forBaseAndKey(@, key)
get: (key) ->
return undefined if typeof key is 'undefined'
@property(key).getValue()
set: (key, val) ->
return undefined if typeof key is 'undefined'
@property(key).setValue(val)
unset: (key) ->
return undefined if typeof key is 'undefined'
@property(key).unsetValue() |
| forget: (key, observer) ->
if key
@property(key).forget(observer)
else
@_batman.properties.forEach (key, property) -> property.forget()
@ |
| allowed: (key) ->
@property(key).isAllowedToFire() |
| for k in ['observe', 'prevent', 'allow', 'fire']
do (k) ->
Batman.Observable[k] = (key, args...) ->
@property(key)[k](args...)
@
$get = Batman.get = (object, key) ->
if object.get
object.get(key)
else
Batman.Observable.get.call(object, key) |
Events | |
| Batman.EventEmitter = |
An event is a convenient observer wrapper. Any function can be wrapped in an event, and
when called, it will cause it's object to fire all the observers for that event. There is
also some syntactical sugar so observers can be registered simply by calling the event with a
function argument. Notice that the | event: $block (key, context, callback) ->
if not callback and typeof context isnt 'undefined'
callback = context
context = null
if not callback and $typeOf(key) isnt 'String'
callback = key
key = null |
Return a function which either takes another observer to register or a value to fire the event with. | f = (observer) ->
if not @observe
throw "EventEmitter requires Observable"
Batman.initializeObject @
key ||= $findName(f, @)
fired = @_batman.oneShotFired?[key] |
Pass a function to the event to register it as an observer. | if typeof observer is 'function'
@observe key, observer
observer.apply(@, f._firedArgs) if f.isOneShot and fired |
Otherwise, calling the event will cause it to fire. Any arguments you pass will be passed to your wrapped function. | else if @allowed key
return false if f.isOneShot and fired
value = callback?.apply @, arguments |
Observers will only fire if the result of the event is not false. | if value isnt false |
Get and cache the arguments for the event listeners. Add the value if its not undefined, and then concat any more arguments passed to this event when fired. | f._firedArgs = unless typeof value is 'undefined'
[value].concat arguments...
else
if arguments.length == 0
[]
else
Array.prototype.slice.call arguments |
Copy the array and add in the key for | args = Array.prototype.slice.call f._firedArgs
args.unshift key
@fire(args...)
if f.isOneShot
firings = @_batman.oneShotFired ||= {}
firings[key] = yes
value
else
false |
This could be its own mixin but is kept here for brevity. | f = f.bind(context) if context
@[key] = f if key?
$mixin f,
isEvent: yes
action: callback |
One shot events can be used for something that only fires once. Any observers
added after it has already fired will simply be executed immediately. This is useful
for things like | eventOneShot: (callback) ->
$mixin Batman.EventEmitter.event.apply(@, arguments),
isOneShot: yes
oneShotFired: @oneShotFired.bind @
oneShotFired: (key) ->
Batman.initializeObject @
firings = @_batman.oneShotFired ||= {}
!!firings[key] |
| Batman.event = $event = (callback) ->
context = new Batman.Object
context.event('_event', context, callback) |
| Batman.eventOneShot = $eventOneShot = (callback) ->
context = new Batman.Object
oneShot = context.eventOneShot('_event', context, callback)
oneShot.oneShotFired = ->
context.oneShotFired('_event')
oneShot |
Objects | |
| Batman.initializeObject = (object) ->
if object._batman?
object._batman.check(object)
else
object._batman = new _Batman(object) |
_Batman provides a convienient, parent class and prototype aware place to store hidden
object state. Things like observers, accessors, and states belong in the | Batman._Batman = class _Batman
constructor: (@object, mixins...) ->
$mixin(@, mixins...) if mixins.length > 0 |
Used by | check: (object) ->
if object != @object
object._batman = new _Batman(object)
return false
return true |
| get: (key) -> |
Get all the keys from the ancestor chain | results = @getAll(key)
switch results.length
when 0
undefined
when 1
results[0]
else |
And then try to merge them if there is more than one. Use | if results[0].concat?
results = results.reduceRight (a, b) -> a.concat(b)
else if results[0].merge?
results = results.reduceRight (a, b) -> a.merge(b)
results |
| getFirst: (key) ->
results = @getAll(key)
results[0] |
| getAll: (keyOrGetter) -> |
Get a function which pulls out the key from the ancestor's | if typeof keyOrGetter is 'function'
getter = keyOrGetter
else
getter = (ancestor) -> ancestor._batman?[keyOrGetter] |
Apply it to all the ancestors, and then this | results = @ancestors(getter)
if val = getter(@object)
results.unshift val
results |
| ancestors: (getter = (x) -> x) ->
results = [] |
Decide if the object is a class or not, and pull out the first ancestor | isClass = !!@object.prototype
parent = if isClass
@object.__super__?.constructor
else
if (proto = Object.getPrototypeOf(@object)) == @object
@object.constructor.__super__
else
proto
if parent? |
Apply the function and store the result if it isn't undefined. | val = getter(parent)
results.push(val) if val? |
Use a recursive call to | results = results.concat(parent._batman.ancestors(getter)) if parent._batman?
results
set: (key, value) ->
@[key] = value |
| class BatmanObject |
Setting | @global: (isGlobal) ->
return if isGlobal is false
container[@name] = @ |
Apply mixins to this class. | @classMixin: -> $mixin @, arguments... |
Apply mixins to instances of this class. | @mixin: -> @classMixin.apply @prototype, arguments
mixin: @classMixin |
Accessor implementation. Accessors are used to create properties on a class or prototype which can be fetched
with get, but are computed instead of just stored. This is a batman and old browser friendly version of
Accessors track which other properties they rely on for computation, and when those other properties change,
an accessor will recalculate its value and notifiy its observers. This way, when a source value is changed,
any dependent accessors will automatically update any bindings to them with a new value. Accessors accomplish
this feat by tracking
Note: This function gets called in all sorts of different contexts by various
other pointers to it, but it acts the same way on | getAccessorObject = (accessor) ->
accessor = {get: accessor} if !accessor.get && !accessor.set && !accessor.unset
accessor
@classAccessor: (keys..., accessor) ->
Batman.initializeObject @ |
Create a default accessor if no keys have been given. | if keys.length is 0 |
The | @_batman.defaultAccessor = getAccessorObject(accessor)
else |
Otherwise, add key accessors for each key given. | @_batman.keyAccessors ||= new Batman.SimpleHash
@_batman.keyAccessors.set(key, getAccessorObject(accessor)) for key in keys |
Support adding accessors to the prototype from within class defintions or after the class has been created
with | @accessor: -> @classAccessor.apply @prototype, arguments |
Support adding accessors to instances after creation | accessor: @classAccessor
constructor: (mixins...) ->
@_batman = new _Batman(@)
@mixin mixins... |
Make every subclass and their instances observable. | @classMixin Batman.Observable, Batman.EventEmitter
@mixin Batman.Observable, Batman.EventEmitter |
Observe this property on every instance of this class. | @observeAll: -> @::observe.apply @prototype, arguments
@singleton: (singletonMethodName="sharedInstance") ->
@classAccessor singletonMethodName,
get: -> @["_#{singletonMethodName}"] ||= new @
Batman.Object = BatmanObject
class Batman.SimpleHash
constructor: ->
@_storage = {}
@length = 0
hasKey: (key) ->
matches = @_storage[key] ||= []
for match in matches
if @equality(match[0], key)
pair = match
return true
return false
get: (key) ->
return undefined if typeof key is 'undefined'
if matches = @_storage[key]
for [obj,v] in matches
return v if @equality(obj, key)
set: (key, val) ->
return undefined if typeof key is 'undefined'
matches = @_storage[key] ||= []
for match in matches
if @equality(match[0], key)
pair = match
break
unless pair
pair = [key]
matches.push(pair)
@length++
pair[1] = val
unset: (key) ->
if matches = @_storage[key]
for [obj,v], index in matches
if @equality(obj, key)
matches.splice(index,1)
@length--
return
equality: (lhs, rhs) ->
return false if typeof lhs is 'undefined' or typeof rhs is 'undefined'
if typeof lhs.isEqual is 'function'
lhs.isEqual rhs
else if typeof rhs.isEqual is 'function'
rhs.isEqual lhs
else
lhs is rhs
forEach: (iterator) ->
for key, values of @_storage
iterator(obj, value) for [obj, value] in values
keys: ->
result = []
@forEach (obj) -> result.push obj
result
clear: ->
@_storage = {}
@length = 0
isEmpty: ->
@length is 0
merge: (others...) ->
merged = new @constructor
others.unshift(@)
for hash in others
hash.forEach (obj, value) ->
merged.set obj, value
merged
class Batman.Hash extends Batman.Object
constructor: ->
Batman.SimpleHash.apply(@, arguments)
super
@accessor
get: Batman.SimpleHash::get
set: Batman.SimpleHash::set
unset: Batman.SimpleHash::unset
@accessor 'isEmpty', -> @isEmpty()
for k in ['hasKey', 'equality', 'forEach', 'keys', 'isEmpty', 'merge', 'clear']
@::[k] = Batman.SimpleHash::[k]
class Batman.SimpleSet
constructor: ->
@_storage = new Batman.SimpleHash
@length = 0
@add.apply @, arguments if arguments.length > 0
has: (item) ->
@_storage.hasKey item
add: (items...) ->
addedItems = []
for item in items
unless @_storage.hasKey(item)
@_storage.set item, true
addedItems.push item
@length++
@itemsWereAdded(addedItems...) unless addedItems.length is 0
addedItems
remove: (items...) ->
removedItems = []
for item in items
if @_storage.hasKey(item)
@_storage.unset item
removedItems.push item
@length--
@itemsWereRemoved(removedItems...) unless removedItems.length is 0
removedItems
forEach: (iterator) ->
@_storage.forEach (key, value) -> iterator(key)
isEmpty: -> @length is 0
clear: ->
items = @toArray()
@_storage = new Batman.SimpleHash
@length = 0
@itemsWereRemoved(items)
items
toArray: ->
@_storage.keys()
merge: (others...) ->
merged = new @constructor
others.unshift(@)
for set in others
set.forEach (v) -> merged.add v
merged
itemsWereAdded: ->
itemsWereRemoved: ->
class Batman.Set extends Batman.Object
constructor: Batman.SimpleSet
itemsWereAdded: @event ->
itemsWereRemoved: @event ->
for k in ['has', 'forEach', 'isEmpty', 'toArray']
@::[k] = Batman.SimpleSet::[k]
for k in ['add', 'remove', 'clear', 'merge']
do (k) =>
@::[k] = ->
oldLength = @length
results = Batman.SimpleSet::[k].apply(@, arguments)
@property('length').fireDependents()
results
@accessor 'isEmpty', -> @isEmpty()
@accessor 'length', -> @length
class Batman.SortableSet extends Batman.Set
constructor: ->
super
@_indexes = {}
@observe 'activeIndex', =>
@setWasSorted(@)
setWasSorted: @event ->
return false if @length is 0
add: ->
results = super
@_reIndex()
results
remove: ->
results = super
@_reIndex()
results
addIndex: (index) ->
@_reIndex(index)
removeIndex: (index) ->
@_indexes[index] = null
delete @_indexes[index]
@unset('activeIndex') if @activeIndex is index
index
forEach: (iterator) ->
iterator(el) for el in @toArray()
sortBy: (index) ->
@addIndex(index) unless @_indexes[index]
@set('activeIndex', index) unless @activeIndex is index
@
isSorted: ->
@_indexes[@get('activeIndex')]?
toArray: ->
@_indexes[@get('activeIndex')] || super
_reIndex: (index) ->
if index
[keypath, ordering] = index.split ' '
ary = Batman.Set.prototype.toArray.call @
@_indexes[index] = ary.sort (a,b) ->
valueA = (Batman.Observable.property.call(a, keypath)).getValue()?.valueOf()
valueB = (Batman.Observable.property.call(b, keypath)).getValue()?.valueOf()
[valueA, valueB] = [valueB, valueA] if ordering?.toLowerCase() is 'desc'
if valueA < valueB then -1 else if valueA > valueB then 1 else 0
@setWasSorted(@) if @activeIndex is index
else
@_reIndex(index) for index of @_indexes
@setWasSorted(@)
@ |
State Machines | Batman.StateMachine = {
initialize: ->
Batman.initializeObject @
if not @_batman.states
@_batman.states = new Batman.SimpleHash
@accessor 'state',
get: -> @state()
set: (key, value) -> _stateMachine_setState.call(@, value)
state: (name, callback) ->
Batman.StateMachine.initialize.call @
if not name
return @_batman.getFirst 'state'
if not @event
throw "StateMachine requires EventEmitter"
event = @[name] || @event name, -> _stateMachine_setState.call(@, name); false
event.call(@, callback) if typeof callback is 'function'
event
transition: (from, to, callback) ->
Batman.StateMachine.initialize.call @
@state from
@state to
name = "#{from}->#{to}"
transitions = @_batman.states
event = transitions.get(name) || transitions.set(name, $event ->)
event(callback) if callback
event
} |
A special method to alias state machine methods to class methods | Batman.Object.actsAsStateMachine = (includeInstanceMethods=true) ->
Batman.StateMachine.initialize.call @
Batman.StateMachine.initialize.call @prototype
@classState = -> Batman.StateMachine.state.apply @, arguments
@state = -> @classState.apply @prototype, arguments
@::state = @classState if includeInstanceMethods
@classTransition = -> Batman.StateMachine.transition.apply @, arguments
@transition = -> @classTransition.apply @prototype, arguments
@::transition = @classTransition if includeInstanceMethods |
This is cached here so it doesn't need to be recompiled for every setter | _stateMachine_setState = (newState) ->
Batman.StateMachine.initialize.call @
if @_batman.isTransitioning
(@_batman.nextState ||= []).push(newState)
return false
@_batman.isTransitioning = yes
oldState = @state()
@_batman.state = newState
if newState and oldState
name = "#{oldState}->#{newState}"
for event in @_batman.getAll((ancestor) -> ancestor._batman?.get('states')?.get(name))
if event
event newState, oldState
if newState
@fire newState, newState, oldState
@_batman.isTransitioning = no
@[@_batman.nextState.shift()]() if @_batman.nextState?.length
newState |
App, Requests, and Routing | |
| class Batman.Request extends Batman.Object
url: ''
data: ''
method: 'get'
response: null |
After the URL gets set, we'll try to automatically send your request after a short period. If this behavior is not desired, use @cancel() after setting the URL. | @observeAll 'url', ->
@_autosendTimeout = setTimeout (=> @send()), 0
loading: @event ->
loaded: @event ->
success: @event ->
error: @event -> |
| send: () -> throw "Please source a dependency file for a request implementation"
cancel: ->
clearTimeout(@_autosendTimeout) if @_autosendTimeout |
| class Batman.App extends Batman.Object |
Require path tells the require methods which base directory to look in. | @requirePath: '' |
The require class methods ( | @require: (path, names...) ->
base = @requirePath + path
for name in names
@prevent 'run'
path = base + '/' + name + '.coffee' # FIXME: don't hardcode this
new Batman.Request
url: path
type: 'html'
success: (response) =>
CoffeeScript.eval response |
FIXME: under no circumstances should we be compiling coffee in the browser. This can be fixed via a real deployment solution to compile coffeescripts, such as Sprockets. | @allow 'run'
@run() # FIXME: this should only happen if the client actually called run.
@
@controller: (names...) ->
names = names.map (n) -> n + '_controller'
@require 'controllers', names...
@model: ->
@require 'models', arguments...
@view: ->
@require 'views', arguments... |
Layout is the base view that other views can be yielded into. The
default behavior is that when | @layout: undefined |
Call | @run: @eventOneShot ->
if Batman.currentApp
return if Batman.currentApp is @
Batman.currentApp.stop()
return false if @hasRun
Batman.currentApp = @
if typeof @dispatcher is 'undefined'
@dispatcher ||= new Batman.Dispatcher @
if typeof @layout is 'undefined'
@set 'layout', new Batman.View
contexts: [@]
node: document
if typeof @historyManager is 'undefined' and @dispatcher.routeMap
@historyManager = Batman.historyManager = new Batman.HashHistory @
@historyManager.start()
@hasRun = yes
@
@stop: @eventOneShot ->
@historyManager?.stop()
Batman.historyManager = null
@hasRun = no
@ |
Dispatcher | class Batman.Route extends Batman.Object |
Route regexes courtesy of Backbone | namedParam = /:([\w\d]+)/g
splatParam = /\*([\w\d]+)/g
queryParam = '(?:\\?.+)?'
namedOrSplat = /[:|\*]([\w\d]+)/g
escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g
constructor: ->
super
@pattern = @url.replace(escapeRegExp, '\\$&')
@regexp = new RegExp('^' + @pattern.replace(namedParam, '([^\/]*)').replace(splatParam, '(.*?)') + queryParam + '$')
@namedArguments = []
while (array = namedOrSplat.exec(@pattern))?
@namedArguments.push(array[1]) if array[1]
@accessor 'action',
get: ->
return @action if @action
if @options
result = $mixin {}, @options
if signature = result.signature
components = signature.split('#')
result.controller = components[0]
result.action = components[1] || 'index'
result.target = @dispatcher.get result.controller
@set 'action', result
set: (key, action) ->
@action = action
parameterize: (url) ->
[url, query] = url.split '?'
array = @regexp.exec(url)?.slice(1)
params = url: url
action = @get 'action'
if typeof action is 'function'
params.action = action
else
$mixin params, action
if array
for param, index in array
params[@namedArguments[index]] = param
if query
for s in query.split '&'
[key, value] = s.split '='
params[key] = value
params
dispatch: (url) ->
if $typeOf(url) is 'String'
params = @parameterize url
$redirect('/404') if not (action = params.action) and url isnt '/404'
return action(params) if typeof action is 'function'
return params.target.dispatch(action, params) if params.target?.dispatch
return params.target?[action](params)
class Batman.Dispatcher extends Batman.Object
constructor: (@app) ->
@app.route @
@app.controllers = new Batman.Object
for key, controller of @app
continue unless controller?.prototype instanceof Batman.Controller
@prepareController controller
prepareController: (controller) ->
name = helpers.underscore(controller.name.replace('Controller', ''))
return unless name
getter = -> @[name] = controller.get 'sharedController'
@accessor name, getter
@app.controllers.accessor name, getter
register: (url, options) ->
url = "/#{url}" if url.indexOf('/') isnt 0
route = if $typeOf(options) is 'Function'
new Batman.Route url: url, action: options, dispatcher: @
else
new Batman.Route url: url, options: options, dispatcher: @
@routeMap ||= {}
@routeMap[url] = route
findRoute: (url) ->
url = "/#{url}" if url.indexOf('/') isnt 0
return route if (route = @routeMap[url])
for routeUrl, route of @routeMap
return route if route.regexp.test(url)
findUrl: (params) ->
for url, route of @routeMap
matches = no
if params.resource
if route.options.resource is params.resource
matches = yes
else
action = route.get 'action'
continue if typeof action is 'function'
{controller, action} = action
if controller is params.controller and action is (params.action || 'index')
matches = yes
continue if not matches
for key, value of params
url = url.replace new RegExp('[:|\*]' + key), value
return url
dispatch: (url) ->
route = @findRoute(url)
if route
route.dispatch(url)
else if url isnt '/404'
$redirect('/404') |
History Manager | class Batman.HistoryManager
constructor: (@app) ->
dispatch: (url) ->
url = "/#{url}" if url.indexOf('/') isnt 0
@app.dispatcher.dispatch url
url
redirect: (url) ->
if $typeOf(url) isnt 'String'
url = @app.dispatcher.findUrl(url)
@dispatch url
class Batman.HashHistory extends Batman.HistoryManager
HASH_PREFIX: '#!'
start: =>
return if typeof window is 'undefined'
return if @started
@started = yes
if 'onhashchange' of window
window.addEventListener 'hashchange', @parseHash, false
else
@interval = setInterval @parseHash, 100
setTimeout @parseHash, 0
stop: =>
if @interval
@interval = clearInterval @interval
else
window.removeEventListener 'hashchange', @parseHash, false
@started = no
urlFor: (url) ->
@HASH_PREFIX + url
parseHash: =>
hash = window.location.hash.replace @HASH_PREFIX, ''
return if hash is @cachedHash
@dispatch (@cachedHash = hash)
redirect: (params) ->
url = super
@cachedHash = url
window.location.hash = @HASH_PREFIX + url
Batman.redirect = $redirect = (url) ->
Batman.historyManager?.redirect url |
Route Declarators | Batman.App.classMixin
route: (url, signature, options={}) ->
return if not url
if url instanceof Batman.Dispatcher
dispatcher = url
for key, value of @_dispatcherCache
dispatcher.register key, value
@_dispatcherCache = null
return dispatcher
if $typeOf(signature) is 'String'
options.signature = signature
else if $typeOf(signature) is 'Function'
options = signature
else if signature
$mixin options, signature
@_dispatcherCache ||= {}
@_dispatcherCache[url] = options
root: (signature, options) ->
@route '/', signature, options
resources: (resource, options, callback) ->
(callback = options; options = null) if typeof options is 'function'
controller = options?.controller || resource
@route(resource, "#{controller}#index").resource = controller
@route("#{resource}/:id", "#{controller}#show").resource = controller
@route("#{resource}/:id/edit", "#{controller}#edit").resource = controller
@route("#{resource}/:id/destroy", "#{controller}#destroy").resource = controller
if callback
app = @
ops =
collection: (collectionCallback) ->
collectionCallback?.call route: (url, methodName) -> app.route "#{resource}/#{url}", "#{controller}##{methodName || url}"
member: (memberCallback) ->
memberCallback?.call route: (url, methodName) -> app.route "#{resource}/:id/#{url}", "#{controller}##{methodName || url}"
callback.call ops
redirect: $redirect |
Controllers | class Batman.Controller extends Batman.Object
@singleton 'sharedController'
@beforeFilter: (nameOrFunction) ->
filters = @_batman.beforeFilters ||= []
filters.push(nameOrFunction) if ~filters.indexOf(nameOrFunction)
@accessor 'controllerName',
get: -> @_controllerName ||= helpers.underscore(@constructor.name.replace('Controller', ''))
@accessor 'action',
get: -> @_currentAction
set: (key, value) -> @_currentAction = value |
You shouldn't call this method directly. It will be called by the dispatcher when a route is called.
If you need to call a route manually, use | dispatch: (action, params = {}) ->
params.controller ||= @get 'controllerName'
params.action ||= action
params.target ||= @
oldRedirect = Batman.historyManager?.redirect
Batman.historyManager?.redirect = @redirect
@_actedDuringAction = no
@set 'action', action
if filters = @constructor._batman?.beforeFilters
filter.call @ for filter in filters
result = @[action](params)
if not @_actedDuringAction and result isnt false
@render()
if filters = @constructor._batman?.afterFilters
filter.call @ for filter in filters
delete @_actedDuringAction
@set 'action', null
Batman.historyManager?.redirect = oldRedirect
$redirect(@_afterFilterRedirect) if @_afterFilterRedirect
delete @_afterFilterRedirect
redirect: (url) =>
throw 'DoubleRedirectError' if @_actedDuringAction
if @get 'action'
@_actedDuringAction = yes
@_afterFilterRedirect = url
else
if $typeOf(url) is 'Object'
url.controller = @ if not url.controller
$redirect url
render: (options = {}) ->
throw 'DoubleRenderError' if @_actedDuringAction
@_actedDuringAction = yes
if not options.view
options.source = helpers.underscore(@constructor.name.replace('Controller', '')) + '/' + @_currentAction + '.html'
options.view = new Batman.View(options)
if view = options.view
view.context ||= @
view.ready ->
Batman.DOM.contentFor('main', view.get('node')) |
Models | class Batman.Model extends Batman.Object |
Model APIOverride this property if your model is indexed by a key other than | @primaryKey: 'id' |
Pick one or many mechanisms with which this model should be persisted. The mechanisms can be already instantiated or just the class defining them. | @persist: (mechanisms...) ->
Batman.initializeObject @prototype
storage = @::_batman.storage ||= []
for mechanism in mechanisms
storage.push if mechanism.isStorageAdapter then mechanism else new mechanism(@)
@ |
Encoders are the tiny bits of logic which manage marshalling Batman models to and from their storage representations. Encoders do things like stringifying dates and parsing them back out again, pulling out nested model collections and instantiating them (and JSON.stringifying them back again), and marshalling otherwise un-storable object. | @encode: (keys..., encoderOrLastKey) ->
Batman.initializeObject @prototype
@::_batman.encoders ||= new Batman.SimpleHash
@::_batman.decoders ||= new Batman.SimpleHash
switch $typeOf(encoderOrLastKey)
when 'String'
keys.push encoderOrLastKey
when 'Function'
encoder = encoderOrLastKey
else
encoder = encoderOrLastKey.encode
decoder = encoderOrLastKey.decode
for key in keys
@::_batman.encoders.set key, (encoder || @defaultEncoder)
@::_batman.decoders.set key, (decoder || @defaultDecoder) |
Set up the unit functions as the default for both | @defaultEncoder = @defaultDecoder = (x) -> (x) |
Validations allow a model to be marked as 'valid' or 'invalid' based on a set of programmatic rules.
By validating our data before it gets to the server we can provide immediate feedback to the user about
what they have entered and forgo waiting on a round trip to the server.
| @validate: (keys..., optionsOrFunction) ->
Batman.initializeObject @prototype
validators = @::_batman.validators ||= []
if typeof optionsOrFunction is 'function' |
Given a function, use that as the actual validator, expecting it to conform to the API the built in validators do. | validators.push
keys: keys
callback: optionsOrFunction
else |
Given options, find the validations which match the given options, and add them to the validators array. | options = optionsOrFunction
for validator in Validators
if (matches = validator.matches(options))
delete options[match] for match in matches
validators.push
keys: keys
validator: new validator(matches) |
Query methods | @classAccessor 'all',
get: ->
unless @all
@all = new Batman.SortableSet
@all.sortBy "id asc"
@all
set: (k,v)-> @all = v
@classAccessor 'first', -> @get('all').toArray()[0]
@classAccessor 'last', -> x = @get('all').toArray(); x[x.length - 1]
@find: (id, callback) ->
record = new @(id)
newRecord = @_mapIdentities([record])[0]
newRecord.load callback
return |
| @load: (options, callback) ->
if !callback
callback = options
options = {}
throw new Error("Can't load model #{@name} without any storage adapters!") unless @::_batman.getAll('storage').length > 0
do @loading
@::_doStorageOperation 'readAll', options, (err, records) =>
if err?
callback?(err, [])
else
callback?(err, @_mapIdentities(records))
do @loaded
@_mapIdentities: (records) ->
all = @get('all').toArray()
newRecords = []
returnRecords = []
for record in records
continue if typeof record is 'undefined'
if typeof (id = record.get('id')) == 'undefined' || id == ''
returnRecords.push record
else
existingRecord = false
for potential in all
if record.get('id') == potential.get('id')
existingRecord = potential
break
if existingRecord
returnRecords.push existingRecord
else
newRecords.push record
returnRecords.push record
@get('all').add(newRecords...) if newRecords.length > 0
returnRecords |
Record API | @accessor 'id',
get: ->
pk = @constructor.get('primaryKey')
if pk == 'id'
@id
else
@get(pk)
set: (k, v) ->
pk = @constructor.get('primaryKey')
if pk == 'id'
@id = v
else
@set(pk, v) |
New records can be constructed by passing either an ID or a hash of attributes (potentially containing an ID) to the Model constructor. By not passing an ID, the model is marked as new. | constructor: (idOrAttributes = {}) -> |
We have to do this ahead of super, because mixins will call set which calls things on dirtyKeys. | @dirtyKeys = new Batman.Hash
@errors = new Batman.ErrorsHash |
Find the ID from either the first argument or the attributes. | if $typeOf(idOrAttributes) is 'Object'
super(idOrAttributes)
else
super()
@set('id', idOrAttributes)
@empty() if not @state() |
Override the | set: (key, value) -> |
Optimize setting where the value is the same as what's already been set. | oldValue = @get(key)
return if oldValue is value |
Actually set the value and note what the old value was in the tracking array. | result = super
@dirtyKeys.set(key, oldValue) |
Mark the model as dirty if isn't already. | @dirty() unless @state() in ['dirty', 'loading', 'creating']
result
toString: ->
"#{@constructor.name}: #{@get('id')}" |
| toJSON: ->
obj = {} |
Encode each key into a new object | encoders = @_batman.get('encoders')
unless !encoders or encoders.isEmpty()
encoders.forEach (key, encoder) =>
val = @get key
if typeof val isnt 'undefined'
encodedVal = encoder(@get key)
if typeof encodedVal isnt 'undefined'
obj[key] = encodedVal
obj |
| fromJSON: (data) ->
obj = {}
decoders = @_batman.get('decoders') |
If no decoders were specified, do the best we can to interpret the given JSON by camelizing each key and just setting the values. | if !decoders or decoders.isEmpty()
for key, value of data
obj[helpers.camelize(key, yes)] = value
else |
If we do have decoders, use them to get the data. | decoders.forEach (key, decoder) ->
obj[key] = decoder(data[key]) |
Mixin the buffer object to use optimized and event-preventing sets used by | @mixin obj |
Each model instance (each record) can be in one of many states throughout its lifetime. Since various operations on the model are asynchronous, these states are used to indicate exactly what point the record is at in it's lifetime, which can often be during a save or load operation. | @actsAsStateMachine yes |
Add the various states to the model. | for k in ['empty', 'dirty', 'loading', 'loaded', 'saving', 'saved', 'creating', 'created', 'validating', 'validated', 'destroying', 'destroyed']
@state k
for k in ['loading', 'loaded']
@classState k
_doStorageOperation: (operation, options, callback) ->
mechanisms = @_batman.get('storage') || []
throw new Error("Can't #{operation} model #{@constructor.name} without any storage adapters!") unless mechanisms.length > 0
for mechanism in mechanisms
mechanism[operation] @, options, callback
true
_hasStorage: -> @_batman.getAll('storage').length > 0 |
| load: (callback) =>
if @get('state') in ['destroying', 'destroyed']
callback?(new Error("Can't save a destroyed record!"))
return
do @loading
@_doStorageOperation 'read', {}, (err, record) =>
do @loaded unless err
callback?(err, @constructor._mapIdentities([record])[0]) |
| save: (callback) =>
if @get('state') in ['destroying', 'destroyed']
callback?(new Error("Can't save a destroyed record!"))
return
@validate (isValid, errors) =>
if !isValid
callback?(errors)
return
creating = @isNew()
do @saving
do @creating if creating
@_doStorageOperation (if creating then 'create' else 'update'), {}, (err, record) =>
unless err
if creating
do @created
do @saved
@dirtyKeys.clear()
callback?(err, @constructor._mapIdentities([record])[0]) |
| destroy: (callback) =>
do @destroying
@_doStorageOperation 'destroy', {}, (err, record) =>
unless err
@constructor.get('all').remove(@)
do @destroyed
callback?(err) |
| validate: (callback) ->
oldState = @state()
@errors.clear()
do @validating
finish = () =>
do @validated
@[oldState]()
callback?(@errors.length == 0, @errors)
validators = @_batman.get('validators') || []
unless validators.length > 0
finish()
else
count = validators.length
validationCallback = =>
if --count == 0
finish()
for validator in validators
v = validator.validator |
Run the validator | for key in validator.keys
if v
v.validateEach @errors, @, key, validationCallback
else
validator.callback @errors, @, key, validationCallback
return
isNew: -> typeof @get('id') is 'undefined' |
| class Batman.ErrorsHash extends Batman.Hash
constructor: -> super(_sets: {}) |
Define a default accessor to instantiate a set for any requested key. | @accessor
get: (key) ->
unless @_sets[key]
@_sets[key] = new Batman.Set
@length++
@_sets[key]
set: Batman.Property.defaultAccessor.set |
Define a shorthand method for adding errors to a key. | add: (key, error) -> @get(key).add(error)
clear: ->
@_sets = {}
super
class Batman.Validator extends Batman.Object
constructor: (@options, mixins...) ->
super mixins...
validate: (record) ->
throw "You must override validate in Batman.Validator subclasses."
@options: (options...) ->
Batman.initializeObject @
if @_batman.options then @_batman.options.concat(options) else @_batman.options = options
@matches: (options) ->
results = {}
shouldReturn = no
for key, value of options
if ~@_batman?.options?.indexOf(key)
results[key] = value
shouldReturn = yes
return results if shouldReturn
Validators = Batman.Validators = [
class Batman.LengthValidator extends Batman.Validator
@options 'minLength', 'maxLength', 'length', 'lengthWithin', 'lengthIn'
constructor: (options) ->
if range = (options.lengthIn or options.lengthWithin)
options.minLength = range[0]
options.maxLength = range[1] || -1
delete options.lengthWithin
delete options.lengthIn
super
validateEach: (errors, record, key, callback) ->
options = @options
value = record.get(key)
if options.minLength and value.length < options.minLength
errors.add key, "#{key} must be at least #{options.minLength} characters"
if options.maxLength and value.length > options.maxLength
errors.add key, "#{key} must be less than #{options.maxLength} characters"
if options.length and value.length isnt options.length
errors.add key, "#{key} must be #{options.length} characters"
callback()
class Batman.PresenceValidator extends Batman.Validator
@options 'presence'
validateEach: (errors, record, key, callback) ->
value = record.get(key)
if @options.presence and !value?
errors.add key, "#{key} must be present"
callback()
]
class Batman.StorageAdapter
constructor: (@model) ->
@modelKey = helpers.pluralize(helpers.underscore(@model.name))
isStorageAdapter: true
getRecordsFromData: (datas) ->
datas = @transformCollectionData(datas) if @transformCollectionData?
for data in datas
@getRecordFromData(data)
getRecordFromData: (data) ->
data = @transformRecordData(data) if @transformRecordData?
record = new @model(data)
class Batman.LocalStorage extends Batman.StorageAdapter
constructor: ->
if typeof window.localStorage is 'undefined'
return null
super
@storage = localStorage
@key_re = new RegExp("^#{@modelKey}(\\d+)$")
@nextId = 1
@_forAllRecords (k, v) ->
if matches = @key_re.exec(k)
@nextId = Math.max(@nextId, parseInt(matches[1], 10) + 1)
return
_forAllRecords: (f) ->
for i in [0...@storage.length]
k = @storage.key(i)
f.call(@, k, @storage.getItem(k))
getRecordFromData: (data) ->
record = super
@nextId = Math.max(@nextId, parseInt(record.get('id'), 10) + 1)
record
update: (record, options, callback) ->
id = record.get('id')
if id?
@storage.setItem(@modelKey + id, JSON.stringify(record))
callback(undefined, record)
else
callback(new Error("Couldn't get record primary key."))
create: (record, options, callback) ->
id = record.get('id') || record.set('id', @nextId++)
if id?
key = @modelKey + id
if @storage.getItem(key)
callback(new Error("Can't create because the record already exists!"))
else
@storage.setItem(key, JSON.stringify(record))
callback(undefined, record)
else
callback(new Error("Couldn't set record primary key on create!"))
read: (record, options, callback) ->
id = record.get('id')
if id?
attrs = JSON.parse(@storage.getItem(@modelKey + id))
if attrs
record.fromJSON(attrs)
callback(undefined, record)
else
callback(new Error("Couldn't find record!"))
else
callback(new Error("Couldn't get record primary key."))
readAll: (_, options, callback) ->
records = []
@_forAllRecords (storageKey, data) ->
if keyMatches = @key_re.exec(storageKey)
match = true
data = JSON.parse(data)
data[@model.primaryKey] ||= parseInt(keyMatches[1], 10)
for k, v of options
if data[k] != v
match = false
break
records.push data if match
callback(undefined, @getRecordsFromData(records))
destroy: (record, options, callback) ->
id = record.get('id')
if id?
key = @modelKey + id
if @storage.getItem key
@storage.removeItem key
callback(undefined, record)
else
callback(new Error("Can't delete nonexistant record!"), record)
else
callback(new Error("Can't delete record without an primary key!"), record)
class Batman.RestStorage extends Batman.StorageAdapter
defaultOptions:
type: 'json'
recordJsonNamespace: false
collectionJsonNamespace: false
constructor: ->
super
@recordJsonNamespace = helpers.singularize(@modelKey)
@collectionJsonNamespace = helpers.pluralize(@modelKey)
@model.encode('id')
transformRecordData: (data) ->
return data[@recordJsonNamespace] if data[@recordJsonNamespace]
data
transformCollectionData: (data) ->
return data[@collectionJsonNamespace] if data[@collectionJsonNamespace]
data
optionsForRecord: (record, idRequired, callback) ->
if record.url
url = if typeof record.url is 'function' then record.url() else record.url
else
url = "/#{@modelKey}"
if idRequired || !record.isNew()
id = record.get('id')
if !id?
callback(new Error("Couldn't get record primary key!"))
return
url = url + "/" + id
unless url
callback.call @, new Error("Couldn't get model url!")
else
callback.call @, undefined, $mixin {}, @defaultOptions, {url, data: JSON.stringify(record)}
optionsForCollection: (recordsOptions, callback) ->
url = @model.url?() || @model.url || "/#{@modelKey}"
unless url
callback.call @, new Error("Couldn't get collection url!")
else
callback.call @, undefined, $mixin {}, @defaultOptions, {url, data: JSON.stringify(recordsOptions)}
create: (record, recordOptions, callback) ->
@optionsForRecord record, false, (err, options) ->
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'PUT'
success: (data) =>
record.fromJSON(@transformRecordData(data))
callback(undefined, record)
error: (err) -> callback(err)
update: (record, recordOptions, callback) ->
@optionsForRecord record, true, (err, options) ->
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'POST'
success: (data) =>
record.fromJSON(@transformRecordData(data))
callback(undefined, record)
error: (err) -> callback(err)
read: (record, recordOptions, callback) ->
@optionsForRecord record, true, (err, options) ->
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'GET'
success: (data) =>
record.fromJSON(@transformRecordData(data))
callback(undefined, record)
error: (err) -> callback(err)
readAll: (_, recordsOptions, callback) ->
@optionsForCollection recordsOptions, (err, options) ->
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'GET'
success: (data) => callback(undefined, @getRecordsFromData(data))
error: (err) -> callback(err)
destroy: (record, optiosn, callback) ->
@optionsForRecord record, true, (err, options) ->
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'DELETE'
success: -> callback(undefined, record)
error: (err) -> callback(err) |
Views | |
A | class Batman.View extends Batman.Object
viewSources = {} |
Set the source attribute to an html file to have that file loaded. | source: '' |
Set the html to a string of html to have that html parsed. | html: '' |
Set an existing DOM node to parse immediately. | node: null
context: null
contexts: null
contentFor: null |
Fires once a node is parsed. | ready: @eventOneShot -> |
Where to look for views on the server | prefix: 'views' |
Whenever the source changes we load it up asynchronously | @observeAll 'source', ->
setTimeout (=> @reloadSource()), 0
reloadSource: ->
source = @get 'source'
return if not source
if viewSources[source]
@set('html', viewSources[source])
else
new Batman.Request
url: "views/#{@source}"
type: 'html'
success: (response) =>
viewSources[source] = response
@set('html', response)
error: (response) ->
throw "Could not load view from #{url}"
@observeAll 'html', (html) ->
node = @node || document.createElement 'div'
node.innerHTML = html
@set('node', node) if @node isnt node
@observeAll 'node', (node) ->
return unless node
@ready.fired = false
if @_renderer
@_renderer.forgetAll() |
We use a renderer with the continuation style rendering engine to not block user interaction for too long during the render. | if node
@_renderer = new Batman.Renderer( node, =>
content = @contentFor
if typeof content is 'string'
@contentFor = Batman.DOM._yields?[content]
if @contentFor and node
@contentFor.innerHTML = ''
@contentFor.appendChild(node)
, @contexts)
@_renderer.rendered =>
@ready node |
Ensure any context object explicitly given for use in rendering the view (in | @_renderer.context.push(@context) if @context
@_renderer.context.set 'view', @ |
DOM Helpers | |
| class Batman.Renderer extends Batman.Object
constructor: (@node, @callback, contexts = []) ->
super
@context = if contexts instanceof RenderContext then contexts else new RenderContext(contexts...)
setTimeout @start, 0
start: =>
@startTime = new Date
@parseNode @node
resume: =>
@startTime = new Date
@parseNode @resumeNode
finish: ->
@startTime = null
@callback?()
@fire 'rendered'
forgetAll: ->
rendered: @eventOneShot ->
bindingRegexp = /data\-(.*)/
sortBindings = (a, b) ->
if a[0] == 'foreach'
-1
else if b[0] == 'foreach'
1
else if a[0] == 'formfor'
-1
else if b[0] == 'formfor'
1
else if a[0] == 'bind'
-1
else if b[0] == 'bind'
1
else
0
parseNode: (node) ->
if new Date - @startTime > 50
@resumeNode = node
setTimeout @resume, 0
return
if node.getAttribute
bindings = for attr in node.attributes
name = attr.nodeName.match(bindingRegexp)?[1]
continue if not name
if ~(varIndex = name.indexOf('-'))
[name.substr(0, varIndex), name.substr(varIndex + 1), attr.value]
else
[name, attr.value]
for readerArgs in bindings.sort(sortBindings)
result = if readerArgs.length == 2
Batman.DOM.readers[readerArgs[0]]?(node, readerArgs[1], @context, @)
else
Batman.DOM.attrReaders[readerArgs[0]]?(node, readerArgs[1], readerArgs[2], @context, @)
if result is false
skipChildren = true
break
if (nextNode = @nextNode(node, skipChildren)) then @parseNode(nextNode) else @finish()
nextNode: (node, skipChildren) ->
if not skipChildren
children = node.childNodes
return children[0] if children?.length
node.onParseExit?()
sibling = node.nextSibling
return sibling if sibling
nextParent = node
while nextParent = nextParent.parentNode
nextParent.onParseExit?()
return if @node.isSameNode(nextParent)
parentSibling = nextParent.nextSibling
return parentSibling if parentSibling
return |
Bindings are shortlived objects which manage the observation of any keypaths a | class Binding extends Batman.Object |
A beastly regular expression for pulling keypaths out of the JSON arguments to a filter. It makes the following matches:
| keypath_rx = ///
(?:^|,) # Match either the start of an arguments list or the start of a space inbetween commas.
\s* # Be insensitive to whitespace between the comma and the actual arguments.
(?! # Use a lookahead to ensure we aren't matching true or false:
(?:true|false) # Match either true or false ...
\s* # and make sure that there's nothing else that comes after the true or false ...
(?:$|,) # before the end of this argument in the list.
)
([a-zA-Z][\w\.]*) # Now that true and false can't be matched, match a dot delimited list of keys.
\s* # Be insensitive to whitespace before the next comma or end of the filter arguments list.
(?:$|,) # Match either the next comma or the end of the filter arguments list.
/// |
A less beastly regular expression for pulling out the [] syntax | get_rx = /(\w)\[(.+?)\]/ |
The | @accessor 'filteredValue', ->
value = @get('unfilteredValue')
if @filterFunctions.length > 0
@filterFunctions.reduce((value, fn, i) => |
Get any argument keypaths from the context stored at parse time. | args = @filterArguments[i].map (argument) ->
if argument._keypath
argument.context.get(argument._keypath)
else
argument |
Apply the filter. | fn(value, args...)
, value)
else
value |
The | @accessor 'unfilteredValue', -> |
If we're working with an | if k = @get('key')
@get("keyContext.#{k}")
else
@get('value') |
The | @accessor 'keyContext', ->
unless @_keyContext
[unfilteredValue, @_keyContext] = @renderContext.findKey @key
@_keyContext
constructor: ->
super |
Pull out the key and filter from the | @parseFilter() |
Define the default observers. | @nodeChange ||= (node, context) =>
if @key
@get('keyContext').set @key, @node.value
@dataChange ||= (value, node) ->
Batman.DOM.valueForNode @node, value
shouldSet = yes |
And attach them. | if Batman.DOM.nodeIsEditable(@node)
Batman.DOM.events.change @node, =>
shouldSet = no
@nodeChange(@node, @_keyContext || @value, @)
shouldSet = yes |
Observe the value of this binding's | @observe 'filteredValue', yes, (value) =>
if shouldSet
@dataChange(value, @node, @)
@
parseFilter: -> |
Store the function which does the filtering and the arguments (all except the actual value to apply the filter to) in these arrays. | @filterFunctions = []
@filterArguments = [] |
Rewrite [] style gets, replace quotes to be JSON friendly, and split the string by pipes to see if there are any filters. | filters = @keyPath.replace(get_rx, "$1 | get $2 ").replace(/'/g, '"').split(/(?!")\s+\|\s+(?!")/) |
The key will is always the first token before the pipe. | try
key = @parseSegment(orig = filters.shift())[0]
catch e
throw "Bad binding keypath \"#{orig}\"!"
if key._keypath
@key = key._keypath
else
@value = key
if filters.length
while filterString = filters.shift() |
For each filter, get the name and the arguments by splitting on the first space. | split = filterString.indexOf(' ')
if ~split
filterName = filterString.substr(0, split)
args = filterString.substr(split)
else
filterName = filterString |
If the filter exists, grab it. | if filter = Batman.Filters[filterName]
@filterFunctions.push filter |
Get the arguments for the filter by parsing the args as JSON, or just pushing an placeholder array | if args
try
@filterArguments.push @parseSegment(args)
catch e
throw new Error("Bad filter arguments \"#{args}\"!")
else
@filterArguments.push []
else
throw new Error("Unrecognized filter '#{filterName}' in key \"#{@keyPath}\"!") |
Map over each array of arguments to grab the context for any keypaths. | @filterArguments = @filterArguments.map (argumentList) =>
argumentList.map (argument) =>
if argument._keypath |
Discard the value (for the time being) and store the context for the keypath in | [_, argument.context] = @renderContext.findKey argument._keypath
argument |
Turn a piece of a | parseSegment: (segment) ->
JSON.parse( "[" + segment.replace(keypath_rx, "{\"_keypath\": \"$1\"}") + "]" ) |
The Render context class manages the stack of contexts accessible to a view during rendering. Every, and I really mean every method which uses filters has to be defined in terms of a new binding, or by using the RenderContext.bind method. This is so that the proper order of objects is traversed and any observers are properly attached. | class RenderContext
constructor: (contexts...) ->
@contexts = contexts
@storage = new Batman.Object
@contexts.push @storage
findKey: (key) ->
base = key.split('.')[0].split('|')[0].trim()
i = @contexts.length
while i--
context = @contexts[i]
if context.get?
val = context.get(base)
else
val = context[base]
if typeof val isnt 'undefined' |
we need to pass the check if the basekey exists, even if the intermediary keys do not. | return [$get(context, key), context]
return [container.get(key), container]
set: (args...) ->
@storage.set(args...)
push: (x) ->
@contexts.push(x)
pop: ->
@contexts.pop()
clone: ->
context = new @constructor(@contexts...)
context.setStorage(@storage)
context
setStorage: (storage) ->
@contexts.splice(@contexts.indexOf(@storage), 1)
@push(storage)
storage |
| class BindingProxy extends Batman.Object
isBindingProxy: true |
Take the | constructor: (@binding, @localKey) ->
if @localKey
@accessor @localKey, -> @binding.get('filteredValue')
else
@accessor (key) -> @binding.get("filteredValue.#{key}") |
Below are the two primitives that all the | addKeyToScopeForNode: (node, key, localName) ->
@bind(node, key, (value, node, binding) =>
@push new BindingProxy(binding, localName)
, ->
true
) |
Pop the | node.onParseExit = =>
@pop() |
| bind: (node, key, dataChange, nodeChange) ->
return new Binding
renderContext: @
keyPath: key
node: node
dataChange: dataChange
nodeChange: nodeChange
Batman.DOM = { |
| readers: {
bind: (node, key, context) ->
if node.nodeName.toLowerCase() == 'input' and node.getAttribute('type') == 'checkbox'
Batman.DOM.attrReaders.bind(node, 'checked', key, context)
else if node.nodeName.toLowerCase() == 'select' |
wait for the select to render before binding to it FIXME expose the renderer's rendered event in the view? | view = context.findKey('view')[0]
view._renderer.rendered ->
Batman.DOM.attrReaders.bind(node, 'value', key, context)
else
context.bind(node, key)
context: (node, key, context) -> context.addKeyToScopeForNode(node, key)
mixin: (node, key, context) ->
context.push(Batman.mixins)
context.bind(node, key, (mixin) ->
$mixin node, mixin
, ->)
context.pop()
showif: (node, key, context, renderer, invert) ->
originalDisplay = node.style.display
originalDisplay = 'block' if !originalDisplay or originalDisplay is 'none'
context.bind(node, key, (value) ->
if !!value is !invert
node.show?()
node.style.display = originalDisplay
else
if typeof node.hide is 'function' then node.hide() else node.style.display = 'none'
, -> )
hideif: (args...) ->
Batman.DOM.readers.showif args..., yes
route: (node, key, context) -> |
you must specify the / in front to route directly to hash route | if key.substr(0, 1) is '/'
url = key
else
route = context.get key
if route instanceof Batman.Model
name = helpers.underscore(helpers.pluralize(route.constructor.name))
url = context.get('dispatcher')?.findUrl({resource: name, id: route.get('id')})
else if route.prototype
name = helpers.underscore(helpers.pluralize(route.name))
url = context.get('dispatcher')?.findUrl({resource: name})
return unless url
if node.nodeName.toUpperCase() is 'A'
node.href = Batman.HashHistory::urlFor url
Batman.DOM.events.click node, (-> $redirect url)
partial: (node, path, context) ->
view = new Batman.View
source: path + '.html'
contentFor: node
contexts: Array.prototype.slice.call(context.contexts)
yield: (node, key) ->
setTimeout (-> Batman.DOM.yield key, node), 0
contentfor: (node, key) ->
setTimeout (-> Batman.DOM.contentFor key, node), 0
} |
| attrReaders: {
_parseAttribute: (value) ->
if value is 'false' then value = false
if value is 'true' then value = true
value
bind: (node, attr, key, context) ->
switch attr
when 'checked', 'disabled'
contextChange = (value) -> node[attr] = !!value
nodeChange = (node, subContext) -> subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node[attr]))
when 'value'
contextChange = (value) -> node.value = value
nodeChange = (node, subContext) -> subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node.value))
else
contextChange = (value) -> node.setAttribute(attr, value)
nodeChange = (node, subContext) -> subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node.getAttribute(attr)))
context.bind(node, key, contextChange, nodeChange)
context: (node, contextName, key, context) -> context.addKeyToScopeForNode(node, key, contextName)
event: (node, eventName, key, context) ->
if key.substr(0, 1) is '@'
callback = new Function key.substr(1)
else
[callback, subContext] = context.findKey key
Batman.DOM.events[eventName] node, ->
confirmText = node.getAttribute('data-confirm')
if confirmText and not confirm(confirmText)
return
x = eventName
x = key
callback?.apply subContext, arguments
addclass: (node, className, key, context, parentRenderer, invert) ->
className = className.replace(/\|/g, ' ') #this will let you add or remove multiple class names in one binding
context.bind node, key, (value) ->
currentName = node.className
includesClassName = currentName.indexOf(className) isnt -1
if !!value is !invert
node.className = "#{currentName} #{className}" if !includesClassName
else
node.className = currentName.replace(className, '') if includesClassName
, ->
removeclass: (args...) ->
Batman.DOM.attrReaders.addclass args..., yes
foreach: (node, iteratorName, key, context, parentRenderer) ->
prototype = node.cloneNode true
prototype.removeAttribute "data-foreach-#{iteratorName}"
parent = node.parentNode
sibling = node.nextSibling
node.onParseExit = ->
setTimeout (-> parent.removeChild node), 0
nodeMap = new Batman.Hash
observers = {}
oldCollection = false
context.bind(node, key, (collection) -> |
Track the old collection so that if it changes, we can remove the observers we attached, and only observe the new collection. | if oldCollection
nodeMap.forEach (item, node) -> parent.removeChild node
oldCollection.forget 'itemsWereAdded', observers.add
oldCollection.forget 'itemsWereRemoved', observers.remove
oldCollection.forget 'setWasSorted', observers.reorder
oldCollection = collection
observers.add = (items...) ->
for item in items
parentRenderer.prevent 'rendered'
newNode = prototype.cloneNode true
nodeMap.set item, newNode
localClone = context.clone()
iteratorContext = new Batman.Object
iteratorContext[iteratorName] = item
localClone.push iteratorContext
localClone.push item
new Batman.Renderer newNode, do (newNode) ->
->
if collection.isSorted?()
observers.reorder()
else
if typeof newNode.show is 'function'
newNode.show before: sibling
else
parent.insertBefore newNode, sibling
parentRenderer.allow 'ready'
parentRenderer.allow 'rendered'
parentRenderer.fire 'rendered'
, localClone
observers.remove = (items...) ->
for item in items
oldNode = nodeMap.get item
nodeMap.unset item
if typeof oldNode.hide is 'function'
oldNode.hide yes
else
oldNode?.parentNode?.removeChild oldNode
true
observers.reorder = ->
items = collection.toArray()
for item in items
thisNode = nodeMap.get(item)
if typeof thisNode.show is 'function'
thisNode.show before: sibling
else
parent.insertBefore(thisNode, sibling) |
Observe the collection for events in the future | if collection?.observe
collection.observe 'itemsWereAdded', observers.add
collection.observe 'itemsWereRemoved', observers.remove
collection.observe 'setWasSorted', observers.reorder |
Add all the already existing items. For hash-likes, add the key. | if collection.forEach
collection.forEach (item) -> observers.add(item)
else for k, v of collection
observers.add(k)
, -> )
false # Return false so the Renderer doesn't descend into this node's children.
formfor: (node, localName, key, context) ->
binding = context.addKeyToScopeForNode(node, key, localName)
Batman.DOM.events.submit node, (node, e) -> e.preventDefault()
} |
| events: {
click: (node, callback) ->
Batman.DOM.addEventListener node, 'click', (args...) ->
callback node, args...
args[0].preventDefault()
if node.nodeName.toUpperCase() is 'A' and not node.href
node.href = '#'
node
change: (node, callback) ->
eventNames = switch node.nodeName.toUpperCase()
when 'TEXTAREA' then ['keyup', 'change']
when 'INPUT'
if node.type.toUpperCase() is 'TEXT'
oldCallback = callback
callback = (e) ->
return if e.type == 'keyup' && 13 <= e.keyCode <= 14
oldCallback(arguments...)
['keyup', 'change']
else
['change']
else ['change']
for eventName in eventNames
Batman.DOM.addEventListener node, eventName, (args...) ->
callback node, args...
submit: (node, callback) ->
if Batman.DOM.nodeIsEditable(node)
Batman.DOM.addEventListener node, 'keyup', (args...) ->
if args[0].keyCode is 13
callback node, args...
args[0].preventDefault()
else
Batman.DOM.addEventListener node, 'submit', (args...) ->
callback node, args...
args[0].preventDefault()
node
} |
| yield: (name, node) ->
yields = Batman.DOM._yields ||= {}
yields[name] = node
if (content = Batman.DOM._yieldContents?[name])
node.innerHTML = ''
node.appendChild(content) if content
contentFor: (name, node) ->
contents = Batman.DOM._yieldContents ||= {}
contents[name] = node
if (yield = Batman.DOM._yields?[name])
yield.innerHTML = ''
yield.appendChild(node) if node
valueForNode: (node, value = '') ->
isSetting = arguments.length > 1
switch node.nodeName.toUpperCase()
when 'INPUT'
if isSetting then (node.value = value) else node.value
when 'TEXTAREA'
if isSetting
node.innerHTML = node.value = value
else
node.innerHTML
when 'SELECT'
node.value = value
else
if isSetting then (node.innerHTML = value) else node.innerHTML
nodeIsEditable: (node) ->
node.nodeName.toUpperCase() in ['INPUT', 'TEXTAREA', 'SELECT']
addEventListener: (node, eventName, callback) ->
if node.addEventListener
node.addEventListener eventName, callback, false
else
node.attachEvent "on#{eventName}", callback
} |
Helpers | camelize_rx = /(?:^|_)(.)/g
capitalize_rx = /(^|\s)([a-z])/g
underscore_rx1 = /([A-Z]+)([A-Z][a-z])/g
underscore_rx2 = /([a-z\d])([A-Z])/g |
Just a few random Rails-style string helpers. You can add more to the Batman.helpers object. | helpers = Batman.helpers = {
camelize: (string, firstLetterLower) ->
string = string.replace camelize_rx, (str, p1) -> p1.toUpperCase()
if firstLetterLower then string.substr(0,1).toLowerCase() + string.substr(1) else string
underscore: (string) ->
string.replace(underscore_rx1, '$1_$2')
.replace(underscore_rx2, '$1_$2')
.replace('-', '_').toLowerCase()
singularize: (string) ->
if string.substr(-3) is 'ies'
string.substr(0, string.length - 3) + 'y'
else if string.substr(-1) is 's'
string.substr(0, string.length - 1)
else
string
pluralize: (count, string) ->
if string
return string if count is 1
else
string = count
lastLetter = string.substr(-1)
if lastLetter is 'y'
"#{string.substr(0,string.length-1)}ies"
else if lastLetter is 's'
string
else
"#{string}s"
capitalize: (string) -> string.replace capitalize_rx, (m,p1,p2) -> p1+p2.toUpperCase()
} |
Filters
| buntUndefined = (f) ->
(value) ->
if typeof value is 'undefined'
undefined
else
f.apply(@, arguments)
filters = Batman.Filters =
get: buntUndefined (value, key) ->
if value.get?
value.get(key)
else
value[key]
not: (value) ->
! !!value
truncate: buntUndefined (value, length, end = "...") ->
if value.length > length
value = value.substr(0, length-end.length) + end
value
default: (value, string) ->
value || string
prepend: (value, string) ->
string + value
append: (value, string) ->
value + string
downcase: buntUndefined (value) ->
value.toLowerCase()
upcase: buntUndefined (value) ->
value.toUpperCase()
pluralize: buntUndefined (string, count) -> helpers.pluralize(count, string)
join: buntUndefined (value, byWhat = '') ->
value.join(byWhat)
sort: buntUndefined (value) ->
value.sort()
map: buntUndefined (value, key) ->
value.map((x) -> x[key])
first: buntUndefined (value) ->
value[0]
for k in ['capitalize', 'singularize', 'underscore', 'camelize']
filters[k] = buntUndefined helpers[k] |
Mixins | mixins = Batman.mixins = new Batman.Object |
Export a few globals, and grab a reference to an object accessible from all contexts for use elsewhere.
In node, the container is the | container = if exports?
module.exports = Batman
global
else
window.Batman = Batman
window
$mixin container, Batman.Observable |
Optionally export global sugar. Not sure what to do with this. | Batman.exportHelpers = (onto) ->
for k in ['mixin', 'unmixin', 'route', 'redirect', 'event', 'eventOneShot', 'typeOf', 'redirect']
onto["$#{k}"] = Batman[k]
onto
Batman.exportGlobals = () ->
Batman.exportHelpers(container)
|