haml-coffee
Version:
Haml templates where you can write inline CoffeeScript.
492 lines (412 loc) • 17.2 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')
{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
# Construct the HAML Coffee compiler.
#
# @param [Object] options the compiler options
# @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 [String] format the template format, either `xhtml`, `html4` or `html5`
# @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
#
constructor: (@options = {}) ->
@options.escapeHtml ?= true
@options.escapeAttributes ?= true
@options.cleanValue ?= true
@options.uglify ?= false
@options.basename ?= false
@options.format ?= 'html5'
@options.preserveTags ?= 'pre,textarea'
@options.selfCloseTags ?= 'meta,img,link,br,hr,input,area,param,col,base'
# 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
# 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
preserveTags : override.preserveTags || @options.preserveTags
selfCloseTags : override.selfCloseTags || @options.selfCloseTags
uglify : override.uglify || @options.uglify
}
# 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)
# 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
@line_number = @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.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
text = line.match ///^\s{#{ (@node.blockLevel * 2) + 2 }}(.*)///
@node.addChild(new Text(text[1], @getNodeOptions({ parentNode: @node }))) if text
# 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 not /^(\s*)[-=&!~.%#<]/.test(lines[0]) and /(?:([-\w]+[\w:-]*\w?|'[-\w]+[\w:-]*\w?'|"[-\w]+[\w:-]*\w?")\s*=\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[\w@.]+)|(:\w+[\w:-]*\w?|'[-\w]+[\w:-]*\w?'|"[-\w]+[\w:-]*\w?")\s*=>\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^},]+)|(\w+[\w:-]*\w?|'[-\w]+[\w:-]*\w?'|'[-\w]+[\w:-]*\w?'):\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^},]+))/.test(lines[0])
attributes = lines.shift()
expression += ' ' + attributes.match(/^(\s*)(.*)/)[2]
@line_number++
# Look ahead for multi line |
if expression.match(/(\s)+\|$/)
expression = expression.replace(/(\s)+\|$/, ' ')
while lines[0]?.match(/(\s)+\|$/)
expression += lines.shift().match(/^(\s*)(.*)/)[2].replace(/(\s)+\|$/, '')
@line_number++
@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
@line_number++
@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.
#
# @param [String] templateName the name to register the template
# @param [String] namespace the namespace to register the template
#
render: (templateName, namespace = 'window.HAML') ->
template = ''
# Create parameter name from the filename, e.g. a file `users/new.hamlc`
# will create `window.HAML.user.new`
segments = "#{ namespace }.#{ templateName }".replace(/(\s|-)+/g, '_').split(/\./)
templateName = if @options.basename then segments.pop().split(/\/|\\/).pop() else segments.pop()
namespace = segments.shift()
# Create code for file and namespace creation
if segments.length isnt 0
for segment in segments
namespace += ".#{ segment }"
template += "#{ namespace } ?= {}\n"
else
template += "#{ namespace } ?= {}\n"
# Render the template
template += "#{ namespace }['#{ templateName }'] = (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, '"')\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) -> if text is null or text is undefined then '' 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 = #{ @options.customSurround }\n"
else
fn += "surround = (start, end, fn) -> start + fn() + end\n"
# Succeed helper
if code.indexOf('succeed') isnt -1
if @options.customSucceed
fn += "succeed = #{ @options.customSucceed }\n"
else
fn += "succeed = (end, fn) -> fn() + end\n"
# Precede helper
if code.indexOf('precede') isnt -1
if @options.customPrecede
fn += "precede = #{ @options.customPrecede }\n"
else
fn += "precede = (start, fn) -> start + fn()\n"
fn += "$o = []\n"
fn += "#{ code }\n"
fn += "return $o.join(\"\\n\")#{ @convertBooleans(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.
#
# 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.
#
# @return [String] the clean up whitespace code if necessary
#
convertBooleans: (code) ->
if @options.format is 'xhtml'
'.replace(/\\s(\\w+)=\'true\'/mg, " $1=\'$1\'").replace(/\\s(\\w+)=\'false\'/mg, \'\')'
else
'.replace(/\\s(\\w+)=\'true\'/mg, \' $1\').replace(/\\s(\\w+)=\'false\'/mg, \'\')'
# 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 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
''