haml-coffee
Version:
Haml templates where you can write inline CoffeeScript.
687 lines (581 loc) • 24.3 kB
text/coffeescript
Node = require('./nodes/node')
Text = require('./nodes/text')
Haml = require('./nodes/haml')
Code = require('./nodes/code')
Comment = require('./nodes/comment')
Filter = require('./nodes/filter')
Directive = require('./nodes/directive')
{whitespace} = require('./util/text')
{indent} = require('./util/text')
# The HamlCoffee class is the compiler that parses the source code and creates an syntax tree.
# In a second step the created tree can be rendered into either a JavaScript function or a
# CoffeeScript template.
#
module.exports = class HamlCoffee
# The current version number.
@VERSION: require('../package.json').version
# Construct the HAML Coffee compiler.
#
# @param [Object] options the compiler options
# @option options [String] placement where to place the resultant function
# @option options [Array<String>] dependencies dependencies for the amd module
# @option options [Boolean] escapeHtml escape the output when true
# @option options [Boolean] escapeAttributes escape the tag attributes when true
# @option options [Boolean] cleanValue clean CoffeeScript values before inserting
# @option options [Boolean] uglify don't indent generated HTML when true
# @option options [Boolean] basename ignore file path when generate the template name
# @option options [Boolean] extendScope extend the template scope with the context
# @option options [String] format the template format, either `xhtml`, `html4` or `html5`
# @option options [String] hyphenateDataAttrs whether to convert underscores to hyphens in data attributes
# @option options [String] preserveTags a comma separated list of tags to preserve content whitespace
# @option options [String] selfCloseTags a comma separated list of self closing HTML tags
# @option options [String] customHtmlEscape the name of the function for HTML escaping
# @option options [String] customCleanValue the name of the function to clean code insertion values before output
# @option options [String] customFindAndPreserve the name of the function used to find and preserve whitespace
# @option options [String] customPreserve the name of the function used to preserve the whitespace
# @option options [String] customReference the name of the function used to create the id from object references
#
constructor: (@options = {}) ->
@options.placement ?= 'global'
@options.dependencies ?= { hc: 'hamlcoffee' }
@options.escapeHtml ?= true
@options.escapeAttributes ?= true
@options.cleanValue ?= true
@options.uglify ?= false
@options.basename ?= false
@options.extendScope ?= false
@options.format ?= 'html5'
@options.hyphenateDataAttrs ?= true
@options.preserveTags ?= 'pre,textarea'
@options.selfCloseTags ?= 'meta,img,link,br,hr,input,area,param,col,base'
if @options.placement is 'global'
@options.name ?= 'test'
@options.namespace ?= 'window.HAML'
# Create parameter name from the filename, e.g. a file `users/new.hamlc`
# will create `window.HAML.user.new`
segments = "#{ @options.namespace }.#{ @options.name }".replace(/(\s|-)+/g, '_').split(/\./)
@options.name = if @options.basename then segments.pop().split(/\/|\\/).pop() else segments.pop()
@options.namespace = segments.shift()
# Create code for file and namespace creation
@intro = ''
if segments.length isnt 0
for segment in segments
@options.namespace += ".#{ segment }"
@intro += "#{ @options.namespace } ?= {}\n"
else
@intro += "#{ @options.namespace } ?= {}\n"
# Test if the indention level has changed, either
# increased or decreased.
#
# @return [Boolean] true when indention changed
#
indentChanged: ->
@currentIndent != @previousIndent
# Test if the indention levels has been increased.
#
# @return [Boolean] true when increased
#
isIndent: ->
@currentIndent > @previousIndent
# Calculate the indention size
#
updateTabSize: ->
@tabSize = @currentIndent - @previousIndent if @tabSize == 0
# Update the current block level indention.
#
updateBlockLevel: ->
@currentBlockLevel = @currentIndent / @tabSize
# Do not validate within comments
unless @node.isCommented()
# Validate current indention
if @currentBlockLevel - Math.floor(@currentBlockLevel) > 0
throw("Indentation error in line #{ @lineNumber }")
# Validate block level
if (@currentIndent - @previousIndent) / @tabSize > 1
throw("Block level too deep in line #{ @lineNumber }")
# Set the indention delta
@delta = @previousBlockLevel - @currentBlockLevel
# Update the indention level for a code block.
#
# @param [Node] node the node to update
#
updateCodeBlockLevel: (node) ->
if node instanceof Code
@currentCodeBlockLevel = node.codeBlockLevel + 1
else
@currentCodeBlockLevel = node.codeBlockLevel
# Update the parent node. This depends on the indention
# if stays the same, goes one down or on up.
#
updateParent: ->
if @isIndent()
@pushParent()
else
@popParent()
# Indention level has been increased:
# Push the current parent node to the stack and make
# the current node the parent node.
#
pushParent: ->
@stack.push @parentNode
@parentNode = @node
# Indention level has been decreased:
# Make the grand parent the current parent.
#
popParent: ->
for i in [0..@delta-1]
@parentNode = @stack.pop()
# Get the options for creating a node
#
# @param [Object] override the options to override
# @return [Object] the node options
#
getNodeOptions: (override = {})->
{
parentNode : override.parentNode || @parentNode
blockLevel : override.blockLevel || @currentBlockLevel
codeBlockLevel : override.codeBlockLevel || @currentCodeBlockLevel
escapeHtml : override.escapeHtml || @options.escapeHtml
escapeAttributes : override.escapeAttributes || @options.escapeAttributes
cleanValue : override.cleanValue || @options.cleanValue
format : override.format || @options.format
hyphenateDataAttrs : override.hyphenateDataAttrs || @options.format
preserveTags : override.preserveTags || @options.preserveTags
selfCloseTags : override.selfCloseTags || @options.selfCloseTags
uglify : override.uglify || @options.uglify
placement : override.placement || @options.placement
namespace : override.namespace || @options.namespace
name : override.name || @options.name
}
# Get the matching node type for the given expression. This
# is also responsible for creating the nested tree structure,
# since there is an exception for creating the node tree:
# Within a filter expression, any empty line without indention
# is added as child to the previous filter expression.
#
# @param [String] expression the HAML expression
# @return [Node] the parser node
#
nodeFactory: (expression = '') ->
options = @getNodeOptions()
# Detect filter node
if expression.match(/^:(escaped|preserve|css|javascript|plain|cdata|coffeescript)/)
node = new Filter(expression, options)
# Detect comment node
else if expression.match(/^(\/|-#)(.*)/)
node = new Comment(expression, options)
# Detect code node
else if expression.match(/^(-#|-|=|!=|\&=|~)\s*(.*)/)
node = new Code(expression, options)
# Detect Haml node
else if expression.match(/^(%|#[^{]|\.|\!)(.*)/)
node = new Haml(expression, options)
# Detect directive node
else if expression.match(/^\+(.*)/)
node = new Directive(expression, options)
# Everything else is a text node
else
node = new Text(expression, options)
options.parentNode?.addChild(node)
node
# Parse the given source and create the nested node
# structure. This parses the source code line be line, but
# looks ahead to find lines that should be merged into the current line.
# This is needed for splitting Haml attributes over several lines
# and also for the different types of filters.
#
# Parsing does not create an output, it creates the syntax tree in the
# compiler. To get the template, use `#render`.
#
# @param [String] source the HAML source code
#
parse: (source = '') ->
# Initialize line and indent markers
@lineNumber = @previousIndent = @tabSize = @currentBlockLevel = @previousBlockLevel = 0
@currentCodeBlockLevel = @previousCodeBlockLevel = 0
# Initialize nodes
@node = null
@stack = []
@root = @parentNode = new Node('', @getNodeOptions())
# Keep lines for look ahead
lines = source.replace(/\r/g, '').split("\n")
# Parse source line by line
while (line = lines.shift()) isnt undefined
# After a filter, all lines are captured as text nodes until the end of the filer
if (@node instanceof Filter) and not @exitFilter
# Blank lines within a filter goes into the filter
if /^(\s)*$/.test(line)
@node.addChild(new Text('', @getNodeOptions({ parentNode: @node })))
# Detect if filter ends or if there is more text
else
result = line.match /^(\s*)(.*)/
ws = result[1]
expression = result[2]
# When on the same or less indent as the filter, exit and continue normal parsing
if @node.blockLevel >= (ws.length / 2)
@exitFilter = true
lines.unshift line
continue
# Get the filter text and remove filter node + indention whitespace
range = if @tabSize > 2 then [@tabSize..1] else [2..1]
for tabsize in range
text = line.match(///^\s{#{ (@node.blockLevel * tabsize) + tabsize }}(.*)///)
if text
@node.addChild(new Text(text[1], @getNodeOptions({ parentNode: @node })))
break
# Normal line handling
else
# Clear exit filter flag
@exitFilter = false
# Get whitespace and Haml expressions
result = line.match /^(\s*)(.*)/
ws = result[1]
expression = result[2]
# Skip empty lines
continue if /^\s*$/.test(line)
# Look ahead for more attributes and add them to the current line
while /^[%.#].*[{(]/.test(expression) and ///^\s{#{ @tabSize + 2 },}///.test(lines[0]) and not /^(\s*)[-=&!~.%#</]/.test(lines[0]) and /([-\w]+[\w:-]*\w?)\s*=|('\w+[\w:-]*\w?')\s*=|("\w+[\w:-]*\w?")\s*=|(\w+[\w:-]*\w?):|('[-\w]+[\w:-]*\w?'):|("[-\w]+[\w:-]*\w?"):|:(\w+[\w:-]*\w?)\s*=>|:?'([-\w]+[\w:-]*\w?)'\s*=>|:?"([-\w]+[\w:-]*\w?)"\s*=>/.test(lines[0])
attributes = lines.shift()
expression = expression.replace(/(\s)+\|\s*$/, '')
expression += ' ' + attributes.match(/^\s*(.*?)(\s+\|\s*)?$/)[1]
@lineNumber++
# Ignore multiple commented lines
while /^-#/.test(expression) and not /^(\s*)[-=&!~.%#</]/.test(lines[0]) and lines.length > 0
lines.shift()
@lineNumber++
# Look ahead for multi line |
if expression.match(/(\s)+\|\s*$/)
expression = expression.replace(/(\s)+\|\s*$/, ' ')
while lines[0]?.match(/(\s)+\|$/)
expression += lines.shift().match(/^(\s*)(.*)/)[2].replace(/(\s)+\|\s*$/, '')
@lineNumber++
@currentIndent = ws.length
# Update indention levels and set the current parent
if @indentChanged()
@updateTabSize()
@updateBlockLevel()
@updateParent()
@updateCodeBlockLevel(@parentNode)
# Create current node
@node = @nodeFactory(expression)
# Save previous indention levels
@previousBlockLevel = @currentBlockLevel
@previousIndent = @currentIndent
@lineNumber++
@evaluate(@root)
# Evaluate the parsed tree
#
# @param [Node] node the node to evaluate
#
evaluate: (node) ->
@evaluate(child) for child in node.children
node.evaluate()
# Render the parsed source code as CoffeeScript template.
#
render: ->
switch @options.placement
when 'amd'
@renderAmd()
when 'standalone'
@renderStandalone()
else
@renderGlobal()
# Render a standalone version
renderStandalone: ->
template = """
return (context) ->
(->
#{indent(@precompile(), 2)}
).call(context)
"""
# Render the parsed source code as CoffeeScript template wrapped in a
# define() statement for AMD. If the global modules list contains a module
# that starts with `hamlcoffee` and is assigned to the `hc` param, then
# all known helper functions will be taken from the `hamlcoffee` helper
# module.
#
# @private
# @return [String] the CoffeeScript template source code
#
renderAmd: ->
if /^hamlcoffee/.test @options.dependencies['hc']
@options.customHtmlEscape = 'hc.escape'
@options.customCleanValue = 'hc.cleanValue'
@options.customPreserve = 'hc.preserve'
@options.customFindAndPreserve = 'hc.findAndPreserve'
@options.customSurround = 'hc.surround'
@options.customSucceed = 'hc.succeed'
@options.customPrecede = 'hc.precede'
@options.customReference = 'hc.reference'
modules = []
params = []
for param, module of @options.dependencies
modules.push module
params.push param
template = indent(@precompile(), 4)
for param, module of @findDependencies(template)
modules.push module
params.push param
if modules.length isnt 0
modules = for m in modules
"'#{ m }'"
modules = "[#{ modules }], (#{ params.join(', ') })"
else
modules = ''
"""
define #{ modules } ->
(context) ->
render = ->
\n#{ template }
render.call(context)
"""
# Render the parsed source code as CoffeeScript template to a global
# window.HAML variable.
#
# @private
# @return [String] the CoffeeScript template source code
#
renderGlobal: ->
# Use the existing intro that is generated at the constructor
template = @intro
# Render the template and extend the scope with the context
if @options.extendScope
template += "#{ @options.namespace }['#{ @options.name }'] = (context) -> ( ->\n"
template += " `with (context || {}) {`\n"
template += "#{ indent(@precompile(), 1) }"
template += "`}`\n"
template += ").call(context)"
# Render the template without extending the scope
else
template += "#{ @options.namespace }['#{ @options.name }'] = (context) -> ( ->\n"
template += "#{ indent(@precompile(), 1) }"
template += ").call(context)"
template
# Pre-compiles the parsed source and generates
# the function source code.
#
# @return [String] the template function source code
#
precompile: ->
fn = ''
code = @createCode()
# Escape HTML entities
if code.indexOf('$e') isnt -1
if @options.customHtmlEscape
fn += "$e = #{ @options.customHtmlEscape }\n"
else
fn += """
$e = (text, escape) ->
"\#{ text }"
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\'/g, ''')
.replace(/\\//g, '/')
.replace(/\"/g, '"')\n
"""
# Check values generated from template code
if code.indexOf('$c') isnt -1
if @options.customCleanValue
fn += "$c = #{ @options.customCleanValue }\n"
else
fn += "$c = (text) ->\n"
fn += " switch text\n"
fn += " when null, undefined then ''\n"
fn += " when true, false then '\u0093' + text\n"
fn += " else text\n"
# Preserve whitespace
if code.indexOf('$p') isnt -1 || code.indexOf('$fp') isnt -1
if @options.customPreserve
fn += "$p = #{ @options.customPreserve }\n"
else
fn += "$p = (text) -> text.replace /\\n/g, '
'\n"
# Find whitespace sensitive tags and preserve
if code.indexOf('$fp') isnt -1
if @options.customFindAndPreserve
fn += "$fp = #{ @options.customFindAndPreserve }\n"
else
fn +=
"""
$fp = (text) ->
text.replace /<(#{ @options.preserveTags.split(',').join('|') })>([^]*?)<\\/\\1>/g, (str, tag, content) ->
"<\#{ tag }>\#{ $p content }</\#{ tag }>"\n
"""
# Surround helper
if code.indexOf('surround') isnt -1
if @options.customSurround
fn += "surround = (start, end, fn) => #{ @options.customSurround }.call(@, start, end, fn)\n"
else
fn += "surround = (start, end, fn) => start + fn.call(@)?.replace(/^\\s+|\\s+$/g, '') + end\n"
# Succeed helper
if code.indexOf('succeed') isnt -1
if @options.customSucceed
fn += "succeed = (start, end, fn) => #{ @options.customSucceed }.call(@, start, end, fn)\n"
else
fn += "succeed = (end, fn) => fn.call(@)?.replace(/\s+$/g, '') + end\n"
# Precede helper
if code.indexOf('precede') isnt -1
if @options.customPrecede
fn += "precede = (start, end, fn) => #{ @options.customPrecede }.call(@, start, end, fn)\n"
else
fn += "precede = (start, fn) => start + fn.call(@)?.replace(/^\s+/g, '')\n"
# Generate object reference
if code.indexOf('$r') isnt -1
if @options.customReference
fn += "$r = #{ @options.customReference }\n"
else
fn += """
$r = (object, prefix) ->
name = if prefix then prefix + '_' else ''
if typeof(object.hamlObjectRef) is 'function'
name += object.hamlObjectRef()
else
name += (object.constructor?.name || 'object').replace(/\W+/g, '_').replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase()
id = if typeof(object.to_key) is 'function'
object.to_key()
else if typeof(object.id) is 'function'
object.id()
else if object.id
object.id
else
object
result = "class='\#{ name }'"
result += " id='\#{ name }_\#{ id }'" if id\n
"""
fn += "$o = []\n"
fn += "#{ code }\n"
fn += "return $o.join(\"\\n\")#{ @convertBooleans(code) }#{ @removeEmptyIDAndClass(code) }#{ @cleanupWhitespace(code) }\n"
# Create the CoffeeScript code for the template.
#
# This gets an array of all lines to be rendered in
# the correct sequence.
#
# @return [String] the CoffeeScript code
#
createCode: ->
code = []
@lines = []
@lines = @lines.concat(child.render()) for child in @root.children
@lines = @combineText(@lines)
@blockLevel = 0
for line in @lines
unless line is null
switch line.type
# Insert static HTML tag
when 'text'
code.push "#{ whitespace(line.cw) }#{ @getBuffer(@blockLevel) }.push \"#{ whitespace(line.hw) }#{ line.text }\""
# Insert code that is only evaluated and doesn't generate any output
when 'run'
if line.block isnt 'end'
code.push "#{ whitespace(line.cw) }#{ line.code }"
# End a block
else
code.push "#{ whitespace(line.cw) }#{ line.code.replace '$buffer', @getBuffer(@blockLevel) }"
@blockLevel -= 1
# Insert code that is evaluated and generates an output
when 'insert'
processors = ''
processors += '$fp ' if line.findAndPreserve
processors += '$p ' if line.preserve
processors += '$e ' if line.escape
processors += '$c ' if @options.cleanValue
code.push "#{ whitespace(line.cw) }#{ @getBuffer(@blockLevel) }.push \"#{ whitespace(line.hw) }\" + #{ processors }#{ line.code }"
# Initialize block output
if line.block is 'start'
@blockLevel += 1
code.push "#{ whitespace(line.cw + 1) }#{ @getBuffer(@blockLevel) } = []"
code.join '\n'
# Get the code buffer identifer
#
# @param [Number] level the block indention level
#
getBuffer: (level) ->
if level > 0 then "$o#{ level }" else '$o'
# Optimize the lines to be rendered by combining subsequent text
# nodes that are on the same code line indention into a single line.
#
# @param [Array<Object>] lines the code lines
# @return [Array<Object>] the optimized lines
#
combineText: (lines) ->
combined = []
while (line = lines.shift()) isnt undefined
if line.type is 'text'
while lines[0] and lines[0].type is 'text' and line.cw is lines[0].cw
nextLine = lines.shift()
line.text += "\\n#{ whitespace(nextLine.hw) }#{ nextLine.text }"
combined.push line
combined
# Adds a boolean convert logic that changes boolean attribute
# values depending on the output format. This works only when
# the clean value function add a hint marker (\u0093) to each
# boolean value, so that the conversion logic can disinguish
# between dynamic, real boolean values and string values like
# 'false' and 'true' or compile time attributes.
#
# With the XHTML format, an attribute `checked='true'` will be
# converted to `checked='checked'` and `checked='false'` will
# be completely removed.
#
# With the HTML4 and HTML5 format, an attribute `checked='true'`
# will be converted to `checked` and `checked='false'` will
# be completely removed.
#
# @param [String] code the CoffeeScript template code
# @return [String] the clean up whitespace code if necessary
#
convertBooleans: (code) ->
if code.indexOf('$c') isnt -1
if @options.format is 'xhtml'
'.replace(/\\s(\\w+)=\'\u0093true\'/mg, " $1=\'$1\'").replace(/\\s(\\w+)=\'\u0093false\'/mg, \'\')'
else
'.replace(/\\s(\\w+)=\'\u0093true\'/mg, \' $1\').replace(/\\s(\\w+)=\'\u0093false\'/mg, \'\')'
else
''
# Remove empty ID and class attribute from the
# final template. In case of the ID this is required
# in order to generate valid HTML.
#
# @param [String] code the CoffeeScript template code
# @return [String] the template code with the code added
#
removeEmptyIDAndClass: (code) ->
if code.indexOf('id=') isnt -1 || code.indexOf('class=') isnt -1
'.replace(/\\s(?:id|class)=([\'"])(\\1)/mg, "")'
else
''
# Adds whitespace cleanup function when needed by the
# template. The cleanup must be done AFTER the template
# has been rendered.
#
# The detection is based on hidden unicode characters that
# are placed as marker into the template:
#
# * `\u0091` Cleanup surrounding whitespace to the left
# * `\u0092` Cleanup surrounding whitespace to the right
#
# @param [String] code the CoffeeScript template code
# @return [String] the clean up whitespace code if necessary
#
cleanupWhitespace: (code) ->
if /\u0091|\u0092/.test code
".replace(/[\\s\\n]*\\u0091/mg, '').replace(/\\u0092[\\s\\n]*/mg, '')"
else
''
# Searches for AMD require statements to find
# all template dependencies.
#
# @example CST source code
# $o.push "" + $c require('assets/templates/test')() => { test: 'assets/templates/test' }
#
# @param [String] code the CoffeeScript template source code
# @return [Object] the module dependencies
#
findDependencies: (code) ->
requireRegexp = /require(?:\s+|\()(['"])(.+?)(\1)\)?/gm
dependencies = {}
while match = requireRegexp.exec code
module = match[2]
name = module.split('/').pop()
dependencies[name] = module
dependencies