UNPKG

haml-coffee

Version:

Haml templates where you can write inline CoffeeScript.

492 lines (412 loc) 17.2 kB
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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/\'/g, '&apos;') .replace(/\"/g, '&quot;')\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, '&#x000A;'\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 ''