tally.coffee 15.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
###
Copyright 2013 Aral Balkan <aral@aralbalkan.com>
Copyright 2012 mocking@gmail.com

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Forked from Distal by mocking@gmail.com (https://code.google.com/p/distal/)
###
19

20 21 22
tally = (root, obj) ->
  "use strict"

23
  # Create a duplicate object which we can add properties to without affecting the original.
24 25 26 27 28 29 30 31 32
  wrapper = ->

  wrapper:: = obj
  obj = new wrapper()
  resolve = tally.resolve
  node = root
  doc = root.ownerDocument
  querySelectorAll = !!root.querySelectorAll

33 34 35 36 37 38 39 40 41 42
  # Create an empty options object if one was not passed so we don’t have to keep checking for it later.
  obj.__tally = {} if obj.__tally is undefined

  # Shortcut to flag: are we running on the server?
  isRunningOnServer = obj.__tally.server

  # Render static option.
  shouldRenderStatic = isRunningOnServer and obj.__tally.renderStatic

  # Optimize comparison check.
43 44
  innerText = (if "innerText" of root then "innerText" else "textContent")

45
  # Attributes that don't support setAttribute()
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
  altAttr =
    className: 1
    class: 1
    innerHTML: 1
    style: 1
    src: 1
    href: 1
    id: 1
    value: 1
    checked: 1
    selected: 1
    label: 1
    htmlFor: 1
    text: 1
    title: 1
    disabled: 1

  formInputHasBody =
    BUTTON: 1
    LABEL: 1
    LEGEND: 1
    FIELDSET: 1
    OPTION: 1


71
  # TAL attributes for querySelectorAll call
72
  qdef = tally
73 74
  attributeWillChange = qdef.attributeWillChange
  textWillChange = qdef.textWillChange
75 76
  qif = qdef.qif or "data-tally-if"
  qrepeat = qdef.qrepeat or "data-tally-repeat"
77
  qattr = qdef.qattr or "data-tally-attribute"
78
  qtext = qdef.qtext or "data-tally-text"
79
  qdup = qdef.qdup or "data-tally-dummy"
80

81
  # Output formatter.
82
  format = qdef.format
83
  qdef = qdef.qdef or "data-tally-alias"
84 85 86 87 88
  TAL = "*[" + [qdef, qif, qrepeat, qattr, qtext].join("],*[") + "]"
  html = undefined
  getProp = (s) ->
    this[s]

89 90
  # There may be generated nodes that are siblings to the root node if the root node
  # itself was a repeater. Remove them so we don't have to deal with them later.
91 92 93
  tmpNode = root.parentNode
  tmpNode.removeChild node  while (node = root.nextSibling) and (node.qdup or (node.nodeType is 1 and node.getAttribute(qdup)))

94 95 96
  # If we generate repeat nodes and are dealing with non-live NodeLists, then
  # we add them to the listStack[] and process them first as they won't appear inline
  # due to non-live NodeLists when we traverse our tree.
97 98 99 100 101 102
  listStack = undefined
  posStack = [0]
  list = undefined
  pos = 0
  attr = undefined
  attr2 = undefined
103
  `var undefined = {}._`
104

105 106
  # Get a list of concerned nodes within this root node. If querySelectorAll is
  # supported we use that but it is treated differently because it is a non-live NodeList.
107 108
  if querySelectorAll

109 110
    # Remove all generated nodes (repeats), so we don't have to deal with them later.
    # Only need to do this for non-live NodeLists.
111 112 113
    list = root.querySelectorAll("*[" + qdup + "]")
    node.parentNode.removeChild node  while (node = list[pos++])
    pos = 0
114

115
  listStack = [(if querySelectorAll then root.querySelectorAll(TAL) else root.getElementsByTagName("*"))]
116

117
  list = [root]
118

119 120 121
  loop
    node = list[pos++]

122 123
    # When finished with the current list, there are generated nodes and
    # their children that need to be processed.
124 125 126
    while not node and (list = listStack.pop())
      pos = posStack.pop()
      node = list[pos++]
127
    break unless node
128

129 130
    # Creates an alias for an object
    # e.g., <section data-tally-alias='feeds main.sidebar.feeds'>
131 132 133 134
    attr = node.getAttribute(qdef)
    if attr
      attr = attr.split(" ")

135
      # Add it to the object as a property.
136 137
      html = resolve(obj, attr[1])

138
      # The 3rd parameter, if it exists, is a numerical index into the array.
139 140 141 142 143
      if attr2 = attr[2]
        obj["#"] = parseInt(attr2) + 1
        html = html[attr2]
      obj[attr[0]] = html

144
    # Shown if object is truthy.
145
    # e.g., <img data-tally-if='item.unread'> <img data-tally-if='item.count isGreaterThan 1'>
146 147 148 149 150 151
    attr = node.getAttribute(qif)
    if attr
      attr = attr.split(" ")
      attr = [attr[0].substr(4), "not", 0]  if attr[0].indexOf("not:") is 0
      obj2 = resolve(obj, attr[0])

Oskar Kalbag's avatar
Oskar Kalbag committed
152
      # If obj is empty array it is still truthy, so make it the array length.
153 154 155 156 157 158 159
      obj2 = obj2.length  if obj2 and obj2.join and obj2.length > -1
      if attr.length > 2
        attr[2] = attr.slice(2).join(" ")  if attr[3]
        attr[2] *= 1  if typeof obj2 is "number"
        switch attr[1]
          when "not"
            attr = not obj2
160
          when "is"     # In Distal, this is eq (equal to)
161
            attr = (obj2 is attr[2])
162
          when "isNot"     # In Distal, this is ne (not equal to)
163
            attr = (obj2 isnt attr[2])
164
          when "isGreaterThan"     # In Distal, this is gt (greater than)
165
            attr = (obj2 > attr[2])
166
          when "isLessThan"     # In Distal, this is lt (less than)
167
            attr = (obj2 < attr[2])
168
          when "contains"     # In Distal, this is cn (contains)
169
            attr = (obj2 and obj2.indexOf(attr[2]) >= 0)
170
          when "doesNotContain"     # In Distal this is nc (does not contain)
171 172
            attr = (obj2 and obj2.indexOf(attr[2]) < 0)
          else
173
            throw new Error(node)
174 175 176
      else
        attr = obj2
      if attr
Oskar Kalbag's avatar
Oskar Kalbag committed
177 178 179 180 181 182
        if not shouldRenderStatic
          if node.style.removeProperty
            node.style.removeProperty 'display'
          else
            node.style.removeAttribute 'display'
        # node.style.display = "" if not shouldRenderStatic
183 184
      else

185 186 187
        # Handle hiding differently based on whether user has flagged that
        # we should render static HTML from the server. (If so, remove the
        # nodes instead of hiding them to cut down on traffic.)
188

189
        # Skip over all nodes that are children of this node.
190 191 192 193
        if querySelectorAll
          pos += node.querySelectorAll(TAL).length
        else
          pos += node.getElementsByTagName("*").length
194 195

        if shouldRenderStatic
196 197 198 199
          node.parentNode.removeChild node
        else
          node.style.display = "none"

Oskar Kalbag's avatar
Oskar Kalbag committed
200
        # Stop processing the rest of this node as it is invisible.
201 202
        continue

Oskar Kalbag's avatar
Oskar Kalbag committed
203 204 205 206
    # Duplicate the current node x number of times where x is the length
    # of the resolved array. Create a shortcut variable for each iteration
    # of the loop.
    # e.g., <div data-tally-repeat='item feeds.items'>
207
    attr = node.getAttribute(qrepeat)
208

209 210 211 212 213 214 215 216
    if attr
      attr2 = attr.split(" ")

      #if live NodeList, remove adjacent repeated nodes
      unless querySelectorAll
        html = node.parentNode
        html.removeChild tmpNode  while (tmpNode = node.nextSibling) and (tmpNode.qdup or (tmpNode.nodeType is 1 and tmpNode.getAttribute(qdup)))

217
      throw new Error(attr2) unless attr2[1]
218 219 220 221 222
      objList = resolve(obj, attr2[1])

      if objList and objList.length

        # Don’t set the style if on the server (as we don’t on anything)
Oskar Kalbag's avatar
Oskar Kalbag committed
223 224 225 226 227 228 229
        # node.style.display = ""  if not shouldRenderStatic
        if not shouldRenderStatic
          if node.style.removeProperty
            node.style.removeProperty 'display'
          else
            node.style.removeAttribute 'display'

230

231 232
        # Allow this node to be treated as index zero in the repeat list
        # we do this by setting the shortcut variable to array[0]
233 234 235 236
        obj[attr2[0]] = objList[0]
        obj["#"] = 1
      else

237
        if shouldRenderStatic
238 239 240 241 242 243 244 245 246 247 248 249
          # Delete the node
          if querySelectorAll
            pos += node.querySelectorAll(TAL).length
          else
            pos += node.getElementsByTagName("*").length

          # Will this mess up pos?
          node.parentNode.removeChild node
        else

          # Just hide the object and skip its children.

250
          # We need to hide the repeat node if the object doesn't resolve.
251 252
          node.style.display = "none"

253
          # Skip over all nodes that are children of this node.
254 255 256 257 258
          if querySelectorAll
            pos += node.querySelectorAll(TAL).length
          else
            pos += node.getElementsByTagName("*").length

259
        # Stop processing the rest of this node as it is invisible.
260
        continue
261

262 263
      if objList.length > 1

264 265 266
        # We need to duplicate this node x number of times. But instead
        # of calling cloneNode x times, we get the outerHTML and repeat
        # that x times, then innerHTML it which is faster.
267 268 269 270 271 272 273 274 275 276 277 278 279 280
        html = new Array(objList.length - 1)
        len = html.length
        i = len

        while i > 0
          html[len - i] = i
          i--
        tmpNode = node.cloneNode(true)
        tmpNode.checked = false  if "form" of tmpNode
        tmpNode.setAttribute qdef, attr
        tmpNode.removeAttribute qrepeat
        tmpNode.setAttribute qdup, "1"
        tmpNode = tmpNode.outerHTML or doc.createElement("div").appendChild(tmpNode).parentNode.innerHTML

281 282
        # We're doing something like this:
        # html = "<div data-tally-alias=' + [1,2,3].join('><div data-tally-alias=') + '>'
283 284 285 286 287 288
        prefix = tmpNode.indexOf(" " + qdef + "=\"" + attr + "\"")
        prefix = tmpNode.indexOf(" " + qdef + "='" + attr + "'")  if prefix is -1
        prefix = prefix + qdef.length + 3 + attr.length
        html = tmpNode.substr(0, prefix) + " " + html.join(tmpNode.substr(prefix) + tmpNode.substr(0, prefix) + " ") + tmpNode.substr(prefix)
        tmpNode = doc.createElement("div")

289
        # Workaround for IE which can't innerHTML tables and selects.
290
        if "cells" of node and not ("tBodies" of node) #TR
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305
          tmpNode.innerHTML = "<table>" + html + "</table>"
          tmpNode = tmpNode.firstChild.tBodies[0].childNodes
        else if "cellIndex" of node #TD
          tmpNode.innerHTML = "<table><tr>" + html + "</tr></table>"
          tmpNode = tmpNode.firstChild.tBodies[0].firstChild.childNodes
        else if "selected" of node and "text" of node #OPTION, OPTGROUP
          tmpNode.innerHTML = "<select>" + html + "</select>"
          tmpNode = tmpNode.firstChild.childNodes
        else
          tmpNode.innerHTML = html
          tmpNode = tmpNode.childNodes
        prefix = node.parentNode
        attr2 = node.nextSibling
        if querySelectorAll or node is root

306 307 308
          # Push the current list and index to the stack and process the repeated
          # nodes first. We need to do this inline because some variable may change
          # value later, if the become redefined.
309 310 311
          listStack.push list
          posStack.push pos

312 313 314 315
          # Add this node to the stack so that it is processed right before we pop the
          # main list off the stack. This will be the last node to be processed and we
          # use it to assign our repeat variable to array index 0 so that the node's
          # children, which are also at array index 0, will be processed correctly.
316 317 318 319 320
          list = getAttribute: getProp
          list[qdef] = attr + " 0"
          listStack.push [list]
          posStack.push 0

321 322
          # Clear the current list so that in the next round we grab another list
          # off the stack.
323 324 325 326 327 328
          list = []
          i = tmpNode.length - 1

          while i >= 0
            html = tmpNode[i]

329 330 331 332 333
            # We need to add the repeated nodes to the listStack because
            # we are either (1) dealing with a live NodeList and we are still at
            # the root node so the newly created nodes are adjacent to the root
            # and so won't appear in the NodeList, or (2) we are dealing with a
            # non-live NodeList, so we need to add them to the listStack.
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
            listStack.push (if querySelectorAll then html.querySelectorAll(TAL) else html.getElementsByTagName("*"))
            posStack.push 0
            listStack.push [html]
            posStack.push 0
            html.qdup = 1
            prefix.insertBefore html, attr2
            i--
        else
          i = tmpNode.length - 1

          while i >= 0
            html = tmpNode[i]
            html.qdup = 1
            prefix.insertBefore html, attr2
            i--
        prefix.selectedIndex = -1

351
    #
352 353
    # Set multiple attributes on the node.
    # e.g., <div data-tally-attribute='value item.text; disabled item.disabled'>
354 355 356
    #

    # Catch empty data-tally-attribute attributes to help in debugging.
357
    attr = node.getAttribute(qattr)
358

359
    if attr
360 361 362 363 364

      # Ignore empty spaces
      attr = attr.trim()

      if attr == ''
365
        throw new Error('empty data-tally-attribute definition on element: ' + node.outerHTML)
366

367 368 369 370 371 372 373 374
      name = undefined
      value = undefined
      html = attr.split("; ")
      i = html.length - 1

      while i >= 0
        attr = html[i].split(" ")
        name = attr[0]
375 376 377 378 379

        if not name
          throw new Error('missing attribute name for attribute ' + i)

        if not attr[1]
380
          throw new Error('missing attribute value for attribute ' + i + ' (\'' + name + '\')')
381

382 383
        value = resolve(obj, attr[1])
        value = ""  if value is `undefined`
384
        attributeWillChange node, name, value  if attributeWillChange
385 386 387 388
        value = attr(value)  if attr = attr[2] and format[attr[2]]
        if altAttr[name]
          switch name
            when "innerHTML" #should use "qtext"
389
              throw new Error(node)
390 391 392 393 394 395 396
            when "disabled", "checked", "selected"
              node[name] = !!value
            when "style"
              node.style.cssText = value
            when "text" #option.text unstable in IE
              node[(if querySelectorAll then name else innerText)] = value
            when "class"
397
              node["className"] = value
398 399 400 401 402
            else
              node[name] = value
        else
          node.setAttribute name, value
        i--
403 404 405 406
    else
      # Try and catch any empty data-tally-attribute attributes to help the user debug.
      if node.hasAttribute
        if node.hasAttribute(qattr)
407
          throw new Error('empty data-tally-attribute definition on element: ' + node.outerHTML)
408

409 410
    # Sets the innerHTML on the node.
    # e.g., <div data-tally-text='html item.description'>
411 412 413 414 415 416
    attr = node.getAttribute(qtext)
    if attr
      attr = attr.split(" ")
      html = (attr[0] is "html")
      attr2 = resolve(obj, attr[(if html then 1 else 0)])
      attr2 = ""  if attr2 is `undefined`
417
      textWillChange node, attr2  if textWillChange
418 419 420 421 422 423 424
      attr2 = attr(attr2)  if (attr = attr[(if html then 2 else 1)]) and (attr = format[attr])
      if html
        node.innerHTML = attr2
      else
        node[(if "form" of node and not formInputHasBody[node.tagName] then "value" else innerText)] = attr2
#end while

425
# Follows the dot notation path to find an object within an object: obj["a"]["b"]["1"] = c;
426 427 428 429 430 431 432 433 434 435 436 437 438 439
tally.resolve = (obj, seq, x, lastObj) ->

  #if fully qualified path is at top level: obj["a.b.d"] = c
  return (if (typeof x is "function") then x.call(obj, seq) else x) if x = obj[seq]

  seq = seq.split(".")
  x = 0

  while seq[x] and (lastObj = obj) and (obj = obj[seq[x++]])
    ;

  (if (typeof obj is "function") then obj.call(lastObj, seq.join(".")) else obj)


440
# Number formatters
441 442 443 444 445
tally.format = ",.": (v, i) ->
  i = v * 1
  (if isNaN(i) then v else ((if i % 1 then i.toFixed(2) else parseInt(i, 10) + "")).replace(/(^\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, "$1,"))


446
# Support RequireJS module pattern
447 448 449 450 451
if typeof define is "function" and define.amd
  define "tally", ->
    tally
else
  window.tally = tally