haml-coffee
Version:
Haml templates where you can write inline CoffeeScript.
669 lines (556 loc) • 22.7 kB
text/coffeescript
Node = require('./node')
{escapeQuotes} = require('../util/text')
# HAML node that contains Haml a haml tag that can have attributes
# and a text or code assignment. There are shortcuts for id and class
# generation and some special logic for merging attributes into existing
# ids and classes.
#
# @example Haml tag
# %footer => <footer></footer>
#
# @example Haml id
# #content => <div id='content'></div>
# %span#status{ :id => @user.status } => <span id='status_#{ @user.status }'></span>
#
# @example Haml classes
# .hidden => <div class='hidden'></div>
# %span.large.hidden => <span class='large hidden'></span>
# .large{ :class => @user.role } => <div class='large #{ @user.role }'></div>
#
# Haml HTML attributes are very limited and allows only simple string
# (with interpolation) or variable assignment to an attribute.
#
# @example Haml HTML attributes
# %p(class='hidden') => <p class='hidden'><p>
# #account(class=@status) => <div id='account' class='#{ status }'></div>
# .logout(title="Logout #{ user.name }") => <div class='logout' title='Logout #{ user.name }'></div>
#
# Ruby HTML attributes are more powerful and allows in addition to the
# HTML attributes function calls:
#
# @example Haml Ruby attributes
# %p{ :class => App.user.get('role') } => <p class='#{ App.user.get('role') }'></p>
#
module.exports = class Haml extends Node
# Evaluate the node content and store the opener tag
# and the closer tag if applicable.
#
evaluate: ->
tokens = @parseExpression(@expression)
# Evaluate Haml doctype
if tokens.doctype
@opener = @markText "#{ escapeQuotes(@buildDocType(tokens.doctype)) }"
# Evaluate Haml tag
else
# Create a Haml node that can contain child nodes
if @isNotSelfClosing(tokens.tag)
prefix = @buildHtmlTagPrefix(tokens)
# Add Haml tag that contains a code assignment will be closed immediately
if tokens.assignment
match = tokens.assignment.match /^(=|!=|&=|~)\s*(.*)$/
identifier = match[1]
assignment = match[2]
if identifier is '~'
code = "\#{$fp #{ assignment } }"
# Code block with escaped code block, either `=` in escaped mode or `&=`
else if identifier is '&=' or (identifier is '=' and @escapeHtml)
if @preserve
if @cleanValue
code = "\#{ $p($e($c(#{ assignment }))) }"
else
code = "\#{ $p($e(#{ assignment })) }"
else
if @cleanValue
code = "\#{ $e($c(#{ assignment })) }"
else
code = "\#{ $e(#{ assignment }) }"
# Code block with unescaped output, either with `!=` or escaped mode to false
else if identifier is '!=' or (identifier is '=' and not @escapeHtml)
if @preserve
if @cleanValue
code = "\#{ $p($c(#{ assignment })) }"
else
code = "\#{ $p(#{ assignment }) }"
else
if @cleanValue
code = "\#{ $c(#{ assignment }) }"
else
code = "\#{ #{ assignment } }"
@opener = @markText "#{ prefix }>#{ code }"
@closer = @markText "</#{ tokens.tag }>"
# A Haml tag that contains an inline text
else if tokens.text
@opener = @markText "#{ prefix }>#{ tokens.text }"
@closer = @markText "</#{ tokens.tag }>"
# A Haml tag that can get more child nodes
else
@opener = @markText prefix + '>'
@closer = @markText "</#{ tokens.tag}>"
# Create a self closing tag that depends on the format `<br>` or `<br/>`
# when it doesn't contain some text
else
tokens.tag = tokens.tag.replace /\/$/, ''
prefix = @buildHtmlTagPrefix(tokens)
if tokens.text
@opener = @markText "#{ prefix }>#{ tokens.text }"
@closer = @markText "</#{ tokens.tag }>"
else
@opener = @markText "#{ prefix }#{ if @format is 'xhtml' then ' /' else '' }>#{ tokens.text }"
# Parses the expression and detect the tag, attributes
# and any assignment. In addition class and id cleanup
# is performed according the the Haml spec:
#
# * Classes are merged together
# * When multiple ids are provided, the last one is taken,
# except they are defined in shortcut notation and attribute
# notation. In this case, they will be combined, separated by
# underscore.
#
# @example Id merging
# #user{ :id => @user.id } => <div id='user_#{ @user.id }'></div>
#
# @param [String] exp the HAML expression
# @return [Object] the parsed tag and options tokens
#
parseExpression: (exp) ->
tag = @parseTag(exp)
@preserve = true if @preserveTags.indexOf(tag.tag) isnt -1
id = @interpolateCodeAttribute(tag.ids?.pop(), true)
classes = tag.classes
attributes = {}
# Clean attributes
if tag.attributes
for key, value of tag.attributes
if key is 'id'
if id
# Merge attribute id into existing id
id += '_' + @interpolateCodeAttribute(value, true)
else
# Push id from attribute
id = @interpolateCodeAttribute(value, true)
# Merge classes
else if key is 'class'
classes or= []
classes.push value
# Add to normal attributes
else
attributes[key] = value
{
doctype : tag.doctype
tag : tag.tag
id : id
classes : classes
text : escapeQuotes(tag.text)
attributes : attributes
assignment : tag.assignment
reference : tag.reference
}
# Parse a tag line. This recognizes DocType tags `!!!` and
# HAML tags like `#id.class text`.
#
# It also parses the code assignment `=`, `}=` and `)=` or
# inline text and the whitespace removal markers `<` and `>`.
#
# It detects an object reference `[` and attributes `(` / `{`.
#
# @param [String] exp the HAML expression
# @return [Object] the parsed tag tokens
#
parseTag: (exp) ->
try
doctype = exp.match(/^(\!{3}.*)/)?[1]
return { doctype: doctype } if doctype
# Match the haml tag %a, .name, etc.
haml = exp.match(/^((?:[#%\.][a-z0-9_:\-]*[\/]?)+)/i)[0]
rest = exp.substring(haml.length)
# The haml tag has attributes
if rest.match /^[{([]/
reference = ''
htmlAttributes = ''
rubyAttributes = ''
for start in ['[', '{', '(', '[', '{', '(']
if start is rest[0]
# Get used attribute surround end character
end = switch start
when '{' then '}'
when '(' then ')'
when '[' then ']'
# Extract attributes by keeping track of brace/parenthesis level
level = 0
for pos in [0..rest.length]
ch = rest[pos]
# Increase level when a nested brace/parenthesis is started
level += 1 if ch is start
# Decrease level when a nested brace/parenthesis is end or exit when on the last level
if ch is end
if level is 1 then break else level -= 1
# Extract result
switch start
when '{'
rubyAttributes += rest.substring(0, pos + 1)
rest = rest.substring(pos + 1)
when '('
htmlAttributes += rest.substring(0, pos + 1)
rest = rest.substring(pos + 1)
when '['
reference = rest.substring(1, pos)
rest = rest.substring(pos + 1)
assignment = rest || ''
# No attributes defined
else
reference = ''
htmlAttributes = ''
rubyAttributes = ''
assignment = rest
# Merge HTML and Ruby style attributes
attributes = {}
for attr in [@parseAttributes(htmlAttributes), @parseAttributes(rubyAttributes)]
attributes[key] = val for key, val of attr
# Extract whitespace removal
if whitespace = assignment.match(/^[<>]{0,2}/)?[0]
assignment = assignment.substring(whitespace.length)
# Remove the delimiter space from the assignment
assignment = assignment.substring(1) if assignment[0] is ' '
# Process inline text or assignment
if assignment and not assignment.match(/^(=|!=|&=|~)/)
text = assignment.replace(/^ /, '')
assignment = undefined
# Set whitespace removal markers
if whitespace
@wsRemoval.around = true if whitespace.indexOf('>') isnt -1
if whitespace.indexOf('<') isnt -1
@wsRemoval.inside = true
@preserve = true
# Extracts tag name, id and classes
tag = haml.match(/\%([a-z_\-][a-z0-9_:\-]*[\/]?)/i)
ids = haml.match(/\#([a-z_\-][a-z0-9_\-]*)/gi)
classes = haml.match(/\.([a-z0-9_\-]*)/gi)
{
tag : if tag then tag[1] else 'div'
ids : ("'#{ id.substr(1) }'" for id in ids) if ids
classes : ("'#{ klass.substr(1) }'" for klass in classes) if classes
attributes : attributes
assignment : assignment
reference : reference
text : text
}
catch error
throw new Error("Unable to parse tag from #{ exp }: #{ error }")
# Parse attributes either in Ruby style `%tag{ :attr => 'value' }`
# or HTML style `%tag(attr='value)`. Both styles can be mixed:
# `%tag(attr='value){ :attr => 'value' }`.
#
# This takes also care of proper attribute interpolation, unwrapping
# quoted keys and value, e.g. `'a' => 'hello'` becomes `a => hello`.
#
# @param [String] exp the HAML expression
# @return [Object] the parsed attributes
#
parseAttributes: (exp) ->
attributes = {}
return attributes if exp is undefined
type = exp.substring(0, 1)
# Mark key separator characters within quoted values, so they aren't recognized as keys.
exp = exp.replace /(=|:|=>)\s*('([^\\']|\\\\|\\')*'|"([^\\"]|\\\\|\\")*")/g, (match, type, value) ->
type + value?.replace /(:|=|=>)/g, '\u0090$1'
# Mark key separator characters within parenthesis, so they aren't recognized as keys.
level = 0
start = 0
markers = []
if type is '('
startPos = 1
endPos = exp.length - 1
else
startPos = 0
endPos = exp.length
for pos in [startPos...endPos]
ch = exp[pos]
# Increase level when a parenthesis is started
if ch is '('
level += 1
start = pos if level is 1
# Decrease level when a parenthesis is end
if ch is ')'
if level is 1
markers.push({ start: start, end: pos }) if start isnt 0 and pos - start isnt 1
else
level -= 1
for marker in markers.reverse()
exp = exp.substring(0, marker.start) + exp.substring(marker.start, marker.end).replace(/(:|=|=>)/g, '\u0090$1') + exp.substring(marker.end)
# Detect the used key type
switch type
when '('
# HTML attribute keys
keys = ///
\(\s*([-\w]+[\w:-]*\w?)\s*=
|
\s+([-\w]+[\w:-]*\w?)\s*=
|
\(\s*('\w+[\w:-]*\w?')\s*=
|
\s+('\w+[\w:-]*\w?')\s*=
|
\(\s*("\w+[\w:-]*\w?")\s*=
|
\s+("\w+[\w:-]*\w?")\s*=
///g
when '{'
# Ruby attribute keys
keys = ///
[{,]\s*(\w+[\w:-]*\w?)\s*:
|
[{,]\s*('[-\w]+[\w:-]*\w?')\s*:
|
[{,]\s*("[-\w]+[\w:-]*\w?")\s*:
|
[{,]\s*:(\w+[\w:-]*\w?)\s*=>
|
[{,]\s*:?'([-\w]+[\w:-]*\w?)'\s*=>
|
[{,]\s*:?"([-\w]+[\w:-]*\w?)"\s*=>
///g
# Split into key value pairs
pairs = exp.split(keys).filter(Boolean)
inDataAttribute = false
hasDataAttribute = false
# Process the pairs in a group of two
while pairs.length
keyValue = pairs.splice 0, 2
# Just a single attribute without value
if keyValue.length is 1
attr = keyValue[0].replace(/^[\s({]+|[\s)}]+$/g, '')
attributes[attr] = 'true'
# Attribute with value or multiple attributes
else
# Trim key and remove preceding colon and remove markers
key = keyValue[0]?.replace(/^\s+|\s+$/g, '').replace(/^:/, '')
key = quoted[2] if quoted = key.match /^("|')(.*)\1$/
# Trim value, remove succeeding comma and remove markers
value = keyValue[1]?.replace(/^\s+|[\s,]+$/g, '').replace(/\u0090/g, '')
if key is 'data' and !value
inDataAttribute = true
hasDataAttribute = true
else if key and value
if inDataAttribute
key = if @hyphenateDataAttrs then "data-#{ key.replace('_', '-') }" else "data-#{ key }"
inDataAttribute = false if /}\s*$/.test value
switch type
when '('
value = value.replace(/^\s+|[\s)]+$/g, '')
# Detect attributes without value as value suffix
quote = /^(['"])/.exec(value)?[1]
pos = value.lastIndexOf quote
if pos > 1
for attr in value.substring(pos + 1).split ' '
attributes[attr] = 'true' if attr
value = value.substring(0, pos + 1)
attributes[key] = value
when '{'
attributes[key] = value.replace(/^\s+|[\s}]+$/g, '')
delete attributes['data'] if hasDataAttribute
attributes
# Build the HTML tag prefix by concatenating all the
# tag information together. The result is an unfinished
# html tag that must be further processed:
#
# @example Prefix tag
# <a id='id' class='class' attr='value'
#
# The Haml spec sorts the `class` names, even when they
# contain interpolated classes. This is supported by
# sorting classes at template render time.
#
# If both an object reference and an id or class attribute is defined,
# then the attribute will be ignored.
#
# @example Template render time sorting
# <p class='#{ [@user.name(), 'show'].sort().join(' ') }'>
#
# @param [Object] tokens all parsed tag tokens
# @return [String] the tag prefix
#
buildHtmlTagPrefix: (tokens) ->
tagParts = ["<#{ tokens.tag }"]
# Set tag classes
if tokens.classes
hasDynamicClass = false
# Prepare static and dynamic class names
classList = for name in tokens.classes
name = @interpolateCodeAttribute(name, true)
hasDynamicClass = true if name.indexOf('#{') isnt -1
name
# Render time classes
if hasDynamicClass && classList.length > 1
classes = '#{ ['
classes += "#{ @quoteAndEscapeAttributeValue(klass, true) }," for klass in classList
classes = classes.substring(0, classes.length - 1) + '].sort().join(\' \').replace(/^\\s+|\\s+$/g, \'\') }'
# Compile time classes
else
classes = classList.sort().join ' '
tagParts.push "class='#{ classes }'"
# Set tag id
tagParts.push "id='#{ tokens.id }'" if tokens.id
# Add id from object reference
if tokens.reference
if tokens.attributes
delete tokens.attributes['class']
delete tokens.attributes['id']
tagParts.push "\#{$r(" + tokens.reference + ")}"
# Construct tag attributes
if tokens.attributes
for key, value of tokens.attributes
# Boolean attribute logic
if value is 'true' or value is 'false'
# Only show true values
if value is 'true'
if @format is 'html5'
tagParts.push "#{ key }"
else
tagParts.push "#{ key }=#{ @quoteAndEscapeAttributeValue(key) }"
# Anything but booleans
else
tagParts.push "#{ key }=#{ @quoteAndEscapeAttributeValue(@interpolateCodeAttribute(value)) }"
tagParts.join(' ')
# Wrap plain attributes into an interpolation for execution.
# In addition wrap it into escaping and cleaning function,
# depending on the options.
#
# @param [String] text the possible code
# @param [Boolean] unwrap unwrap static text from quotes
# @return [String] the text of the wrapped code
#
interpolateCodeAttribute: (text, unwrap = false) ->
return unless text
if not text.match /^("|').*\1$/
if @escapeAttributes
if @cleanValue
text = '#{ $e($c(' + text + ')) }'
else
text = '#{ $e(' + text + ') }'
else
if @cleanValue
text = '#{ $c(' + text + ') }'
else
text = '#{ (' + text + ') }'
if unwrap
text = quoted[2] if quoted = text.match /^("|')(.*)\1$/
text
# Quote the attribute value, depending on its
# content. If the attribute contains an interpolation,
# each interpolation will be cleaned and/or escaped,
# depending on the compiler options.
#
# @param [String] value the without start and end quote
# @param [String] code if we are in a code block
# @return [String] the quoted value
#
quoteAndEscapeAttributeValue: (value, code = false) ->
return unless value
value = quoted[2] if quoted = value.match /^("|')(.*)\1$/
tokens = @splitInterpolations value
hasSingleQuotes = false
hasDoubleQuotes = false
hasInterpolation = false
# Analyse existing quotes and escape/clean interpolations
tokens = for token in tokens
if token[0..1] is '#{'
# Skip interpolated code attributes
if token.indexOf('$e') is -1 and token.indexOf('$c') is -1
if @escapeAttributes
if @cleanValue
token = '#{ $e($c(' + token[2...-1] + ')) }'
else
token = '#{ $e(' + token[2...-1] + ') }'
else
if @cleanValue
token = '#{ $c(' + token[2...-1] + ') }'
hasInterpolation = true
else
hasSingleQuotes = token.indexOf("'") isnt -1 unless hasSingleQuotes
hasDoubleQuotes = token.indexOf('"') isnt -1 unless hasDoubleQuotes
token
if code
if hasInterpolation
result = "\"#{ tokens.join('') }\""
else
result = "'#{ tokens.join('') }'"
else
# Without any qoutes, wrap the value in single quotes
if not hasDoubleQuotes and not hasSingleQuotes
result = "'#{ tokens.join('') }'"
# With only single quotes, wrap the value in double quotes
if hasSingleQuotes and not hasDoubleQuotes
result = "\\\"#{ tokens.join('') }\\\""
# With only double quotes, wrap the value in single quotes and escape the double quotes
if hasDoubleQuotes and not hasSingleQuotes
escaped = for token in tokens
escapeQuotes(token)
result = "'#{ escaped.join('') }'"
# With both type of quotes, wrap the value in single quotes, escape the double quotes and
# convert the single quotes to it's entity representation
if hasSingleQuotes and hasDoubleQuotes
escaped = for token in tokens
escapeQuotes(token).replace(/'/g, ''')
result = "'#{ escaped.join('') }'"
result
# Split expression by its interpolations.
#
# @example
# 'Hello #{ "#{ soso({}) }" } Interpol') => ["Hello ", "#{ "#{ soso({}) }" }", " Interpol"]
# 'Hello #{ "#{ soso }" } Interpol') => ["Hello ", "#{ "#{ soso }" }", " Interpol"]
# 'Hello #{ int } Interpol') => ["Hello ", "#{ int }", " Interpol"]
# 'Hello Interpol') => ["Hello Interpol"]
# '#{ int } Interpol') => ["#{ int }", " Interpol"]
# 'Hello #{ int }') => ["Hello ", "#{ int }"]
# '#{ int }') => ["#{ int }"]
#
# @param [String] value the attribute value
# @return [Array<String>] the splitted string
#
splitInterpolations: (value) ->
level = 0
start = 0
tokens = []
for pos in [0...value.length]
ch = value[pos]
ch2 = value[pos..pos + 1]
if ch is '{'
level += 1
if ch2 is '#{' and level is 0
tokens.push(value[start...pos])
start = pos
if ch is '}'
level -= 1
if level is 0
tokens.push(value[start..pos])
start = pos + 1
tokens.push(value[start...value.length])
tokens.filter(Boolean)
# Build the DocType string depending on the `!!!` token
# and the currently used HTML format.
#
# @param [String] doctype the HAML doctype
# @return [String] the HTML doctype
#
buildDocType: (doctype) ->
switch "#{ @format } #{ doctype }"
when 'xhtml !!! XML' then '<?xml version=\'1.0\' encoding=\'utf-8\' ?>'
when 'xhtml !!!' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'
when 'xhtml !!! 1.1' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">'
when 'xhtml !!! mobile' then '<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">'
when 'xhtml !!! basic' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">'
when 'xhtml !!! frameset' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">'
when 'xhtml !!! 5', 'html5 !!!' then '<!DOCTYPE html>'
when 'html5 !!! XML', 'html4 !!! XML' then ''
when 'html4 !!!' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'
when 'html4 !!! frameset' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">'
when 'html4 !!! strict' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">'
# Test if the given tag is a non-self enclosing tag, by
# matching against a fixed tag list or parse for the self
# closing slash `/` at the end.
#
# @param [String] tag the tag name without brackets
# @return [Boolean] true when a non self closing tag
#
isNotSelfClosing: (tag) ->
@selfCloseTags.indexOf(tag) == -1 && !tag.match(/\/$/)