milk.coffee | |
---|---|
Milk is a simple, fast way to get more Mustache into your CoffeeScript and Javascript. Mustache templates are reasonably simple -- plain text templates are sprinkled with "tags", which are (by default) a pair of curly braces surrounding some bit of content. A good resource for Mustache can be found here. | TemplateCache = {} |
Tags used for working with data get their data by looking up a name in a context stack. This name corresponds to a key in a hash, and the stack is searched top to bottom for an object with given key. Dots in names are special: a single dot ('.') is "top of stack", and dotted names like 'a.b.c' do a chained lookups. | Find = (name, stack, value = null) ->
return stack[stack.length - 1] if name == '.'
[name, parts...] = name.split(/\./)
for i in [stack.length - 1...-1]
continue unless stack[i]?
continue unless typeof stack[i] == 'object' and name of (ctx = stack[i])
value = ctx[name]
break
value = Find(part, [value]) for part in parts |
If we find a function in the stack, we'll treat it as a method, and call it
with | if value instanceof Function
value = do (value) -> ->
val = value.apply(ctx, arguments)
return (val instanceof Function) and val.apply(null, arguments) or val |
Null values will be coerced to the empty string. | return value |
Parsed templates are expanded by simply calling each function in turn. | Expand = (obj, tmpl, args...) -> (f.call(obj, args...) for f in tmpl).join('') |
For parsing, we'll basically need a template string to parse. We do need to remember to take the tag delimiters into account for the cache -- different parse trees can exist for the same template string! | Parse = (template, delimiters = ['{{','}}'], section = null) ->
cache = (TemplateCache[delimiters.join(' ')] ||= {})
return cache[template] if template of cache
buffer = [] |
We'll use a regular expression to handle tag discovery. A proper parser might be faster, but this is simpler, and certainly fast enough for now. Since the tag delimiters may change over time, we'll want to rebuild the regex when they change. | BuildRegex = ->
[tagOpen, tagClose] = delimiters
return ///
([\s\S]*?) # Capture the pre-tag content
([#{' '}\t]*) # Capture the pre-tag whitespace
(?: #{tagOpen} \s* # Match the opening tag
(?:
(!) \s* ([\s\S]+?) | # Comments
(=) \s* ([\s\S]+?) \s* = | # Set Delimiters
({) \s* (\w[\S]*?) \s* } | # Triple Mustaches
([^0-9a-zA-Z._!={]?) \s* ([\w.][\S]*?) # Everything else
)
\s* #{tagClose} ) # Match the closing tag
///gm
tagPattern = BuildRegex()
tagPattern.lastIndex = pos = (section || { start: 0 }).start |
Useful errors should always be prefered - we should compile as much relevant information as possible. | parseError = (pos, msg) ->
(endOfLine = /$/gm).lastIndex = pos
endOfLine.exec(template)
parsedLines = template.substr(0, pos).split('\n')
lineNo = parsedLines.length
lastLine = parsedLines[lineNo - 1]
tagStart = contentEnd + whitespace.length
lastTag = template.substr(tagStart + 1, pos - tagStart - 1)
indent = new Array(lastLine.length - lastTag.length + 1).join(' ')
carets = new Array(lastTag.length + 1).join('^')
lastLine = lastLine + template.substr(pos, endOfLine.lastIndex - pos)
error = new Error()
error[key] = e[key] for key of e =
"message": "#{msg}\n\nLine #{lineNo}:\n#{lastLine}\n#{indent}#{carets}"
"error": msg, "line": lineNo, "char": indent.length, "tag": lastTag
return error |
As we start matching things, let's pull out our captures and build indices. | while match = tagPattern.exec(template)
[content, whitespace] = match[1..2]
type = match[3] || match[5] || match[7] || match[9]
tag = match[4] || match[6] || match[8] || match[10]
contentEnd = (pos + content.length) - 1
pos = tagPattern.lastIndex |
Standalone tags are tags on lines without any non-whitespace characters. | isStandalone = (contentEnd == -1 or template.charAt(contentEnd) == '\n') &&
template.charAt(pos) in [ undefined, '', '\r', '\n' ] |
We should just add static content to the buffer. | buffer.push(do (content) -> -> content) if content |
If we're dealing with a standalone tag that's not interpolation, we should consume the newline immediately following the tag. If we're not, we need to buffer the whitespace we captured earlier. | if isStandalone and type not in ['', '&', '{']
pos += 1 if template.charAt(pos) == '\r'
pos += 1 if template.charAt(pos) == '\n'
else if whitespace
buffer.push(do (whitespace) -> -> whitespace)
contentEnd += whitespace.length
whitespace = '' |
Now we'll handle the tag itself: | switch type |
Comment tags should simply be ignored. | when '!' then break |
Interpolations are handled by finding the value in the context stack, calling and rendering lambdas, and escaping the value if appropriate. | when '', '&', '{'
buildInterpolationTag = (name, is_unescaped) ->
return (context) ->
if (value = Find(name, context) ? '') instanceof Function
value = Expand(this, Parse("#{value()}"), arguments...)
value = @escape("#{value}") unless is_unescaped
return "#{value}"
buffer.push(buildInterpolationTag(tag, type)) |
Partial data is looked up lazily by the given function, indented as appropriate, and then rendered. | when '>'
buildPartialTag = (name, indentation) ->
return (context, partials) ->
partial = partials(name).toString()
partial = partial.replace(/^(?=.)/gm, indentation) if indentation
return Expand(this, Parse(partial), arguments...)
buffer.push(buildPartialTag(tag, whitespace)) |
Sections and Inverted Sections make a recursive parsing pass, allowing us to use the call stack to handle section parsing. This will go until it reaches the matching End Section tag, when it will return the (cached!) template it parsed, along with the index it stopped at. | when '#', '^'
sectionInfo =
name: tag, start: pos
error: parseError(tagPattern.lastIndex, "Unclosed section '#{tag}'!")
[tmpl, pos] = Parse(template, delimiters, sectionInfo) |
Sections are rendered by finding the value in the context stack, coercing it into an array (unless the value is falsey), and rendering the template with each element of the array taking a turn atop the context stack. If the value was a function, the template is filtered through it before rendering. | sectionInfo['#'] = buildSectionTag = (name, delims, raw) ->
return (context) ->
value = Find(name, context) || []
tmpl = if value instanceof Function then value(raw) else raw
value = [value] unless value instanceof Array
parsed = Parse(tmpl || '', delims)
context.push(value)
result = for v in value
context[context.length - 1] = v
Expand(this, parsed, arguments...)
context.pop()
return result.join('') |
Inverted Sections render under almost opposite conditions: their contents will only be rendered when the retrieved value is either falsey or an empty array. | sectionInfo['^'] = buildInvertedSectionTag = (name, delims, raw) ->
return (context) ->
value = Find(name, context) || []
value = [1] unless value instanceof Array
value = if value.length is 0 then Parse(raw, delims) else []
return Expand(this, value, arguments...)
buffer.push(sectionInfo[type](tag, delimiters, tmpl)) |
When the parser encounters an End Section tag, it runs a couple of quick sanity checks, then returns control back to its caller. | when '/'
unless section?
error = "End Section tag '#{tag}' found, but not in section!"
else if tag != (name = section.name)
error = "End Section tag closes '#{tag}'; expected '#{name}'!"
throw parseError(tagPattern.lastIndex, error) if error
template = template[section.start..contentEnd]
cache[template] = buffer
return [template, pos] |
The Set Delimiters tag needs to update the delimiters after some error checking, and rebuild the regular expression we're using to match tags. | when '='
unless (delimiters = tag.split(/\s+/)).length == 2
error = "Set Delimiters tags should have two and only two values!"
throw parseError(tagPattern.lastIndex, error) if error
escape = /[-[\]{}()*+?.,\\^$|#]/g
delimiters = (d.replace(escape, "\\$&") for d in delimiters)
tagPattern = BuildRegex() |
Any other tag type is probably a typo. | else
throw parseError(tagPattern.lastIndex, "Unknown tag type -- #{type}") |
Now that we've finished with this tag, we prepare to parse the next one! | tagPattern.lastIndex = if pos? then pos else template.length |
At this point, we've parsed all the tags. If we've still got a | throw section.error if section? |
All the tags is not all the content; if there's anything left over, append it to the buffer. Then we'll cache the buffer and return it! | buffer.push(-> template[pos..]) unless template.length == pos
return cache[template] = buffer |
Public API | |
The exported object (globally | Milk =
VERSION: '1.2.0' |
Helpers are a form of context, implicitly on the bottom of the stack. This is a global value, and may be either an object or an array. | helpers: [] |
Partials may also be provided globally. | partials: null |
The | escape: (value) ->
entities = { '&': 'amp', '"': 'quot', '<': 'lt', '>': 'gt' }
return value.replace(/[&"<>]/g, (ch) -> "&#{ entities[ch] };") |
Rendering is simple: given a template and some data, it populates the
template. If your template uses Partial Tags, you may also supply a hash or
a function, or simply override | render: (template, data, partials = null) ->
unless (partials ||= @partials || {}) instanceof Function
partials = do (partials) -> (name) ->
throw "Unknown partial '#{name}'!" unless name of partials
return Find(name, [partials])
context = if @helpers instanceof Array then @helpers else [@helpers]
return Expand(this, Parse(template), context.concat([data]), partials) |
Happy hacking! | if exports?
exports[key] = Milk[key] for key of Milk
else
this.Milk = Milk
|