UNPKG

23 kBtext/coffeescriptView Raw
1Node = require('./nodes/node')
2Text = require('./nodes/text')
3Haml = require('./nodes/haml')
4Code = require('./nodes/code')
5Comment = require('./nodes/comment')
6Filter = require('./nodes/filter')
7
8{whitespace} = require('./util/text')
9{indent} = require('./util/text')
10
11# The HamlCoffee class is the compiler that parses the source code and creates an syntax tree.
12# In a second step the created tree can be rendered into either a JavaScript function or a
13# CoffeeScript template.
14#
15module.exports = class HamlCoffee
16
17 # The current version number.
18 @VERSION: '1.9.1'
19
20 # Construct the HAML Coffee compiler.
21 #
22 # @param [Object] options the compiler options
23 # @option options [String] placement where to place the resultant function
24 # @option options [Array<String>] dependencies dependencies for the amd module
25 # @option options [Boolean] escapeHtml escape the output when true
26 # @option options [Boolean] escapeAttributes escape the tag attributes when true
27 # @option options [Boolean] cleanValue clean CoffeeScript values before inserting
28 # @option options [Boolean] uglify don't indent generated HTML when true
29 # @option options [Boolean] basename ignore file path when generate the template name
30 # @option options [Boolean] extendScope extend the template scope with the context
31 # @option options [String] format the template format, either `xhtml`, `html4` or `html5`
32 # @option options [String] preserveTags a comma separated list of tags to preserve content whitespace
33 # @option options [String] selfCloseTags a comma separated list of self closing HTML tags
34 # @option options [String] customHtmlEscape the name of the function for HTML escaping
35 # @option options [String] customCleanValue the name of the function to clean code insertion values before output
36 # @option options [String] customFindAndPreserve the name of the function used to find and preserve whitespace
37 # @option options [String] customPreserve the name of the function used to preserve the whitespace
38 # @option options [String] customReference the name of the function used to create the id from object references
39 #
40 constructor: (@options = {}) ->
41 @options.placement ?= 'global'
42 @options.dependencies ?= { hc: 'hamlcoffee' }
43 @options.escapeHtml ?= true
44 @options.escapeAttributes ?= true
45 @options.cleanValue ?= true
46 @options.uglify ?= false
47 @options.basename ?= false
48 @options.extendScope ?= false
49 @options.format ?= 'html5'
50 @options.preserveTags ?= 'pre,textarea'
51 @options.selfCloseTags ?= 'meta,img,link,br,hr,input,area,param,col,base'
52
53 # Test if the indention level has changed, either
54 # increased or decreased.
55 #
56 # @return [Boolean] true when indention changed
57 #
58 indentChanged: ->
59 @currentIndent != @previousIndent
60
61 # Test if the indention levels has been increased.
62 #
63 # @return [Boolean] true when increased
64 #
65 isIndent: ->
66 @currentIndent > @previousIndent
67
68 # Calculate the indention size
69 #
70 updateTabSize: ->
71 @tabSize = @currentIndent - @previousIndent if @tabSize == 0
72
73 # Update the current block level indention.
74 #
75 updateBlockLevel: ->
76 @currentBlockLevel = @currentIndent / @tabSize
77
78 # Validate current indention
79 if @currentBlockLevel - Math.floor(@currentBlockLevel) > 0
80 throw("Indentation error in line #{ @lineNumber }")
81
82 # Validate block level
83 if (@currentIndent - @previousIndent) / @tabSize > 1
84 # Ignore block level indention errors within comments
85 unless @node.isCommented()
86 throw("Block level too deep in line #{ @lineNumber }")
87
88 # Set the indention delta
89 @delta = @previousBlockLevel - @currentBlockLevel
90
91 # Update the indention level for a code block.
92 #
93 # @param [Node] node the node to update
94 #
95 updateCodeBlockLevel: (node) ->
96 if node instanceof Code
97 @currentCodeBlockLevel = node.codeBlockLevel + 1
98 else
99 @currentCodeBlockLevel = node.codeBlockLevel
100
101 # Update the parent node. This depends on the indention
102 # if stays the same, goes one down or on up.
103 #
104 updateParent: ->
105 if @isIndent()
106 @pushParent()
107 else
108 @popParent()
109
110 # Indention level has been increased:
111 # Push the current parent node to the stack and make
112 # the current node the parent node.
113 #
114 pushParent: ->
115 @stack.push @parentNode
116 @parentNode = @node
117
118 # Indention level has been decreased:
119 # Make the grand parent the current parent.
120 #
121 popParent: ->
122 for i in [0..@delta-1]
123 @parentNode = @stack.pop()
124
125 # Get the options for creating a node
126 #
127 # @param [Object] override the options to override
128 # @return [Object] the node options
129 #
130 getNodeOptions: (override = {})->
131 {
132 parentNode : override.parentNode || @parentNode
133 blockLevel : override.blockLevel || @currentBlockLevel
134 codeBlockLevel : override.codeBlockLevel || @currentCodeBlockLevel
135 escapeHtml : override.escapeHtml || @options.escapeHtml
136 escapeAttributes : override.escapeAttributes || @options.escapeAttributes
137 cleanValue : override.cleanValue || @options.cleanValue
138 format : override.format || @options.format
139 preserveTags : override.preserveTags || @options.preserveTags
140 selfCloseTags : override.selfCloseTags || @options.selfCloseTags
141 uglify : override.uglify || @options.uglify
142 }
143
144 # Get the matching node type for the given expression. This
145 # is also responsible for creating the nested tree structure,
146 # since there is an exception for creating the node tree:
147 # Within a filter expression, any empty line without indention
148 # is added as child to the previous filter expression.
149 #
150 # @param [String] expression the HAML expression
151 # @return [Node] the parser node
152 #
153 nodeFactory: (expression = '') ->
154
155 options = @getNodeOptions()
156
157 # Detect filter node
158 if expression.match(/^:(escaped|preserve|css|javascript|plain|cdata|coffeescript)/)
159 node = new Filter(expression, options)
160
161 # Detect comment node
162 else if expression.match(/^(\/|-#)(.*)/)
163 node = new Comment(expression, options)
164
165 # Detect code node
166 else if expression.match(/^(-#|-|=|!=|\&=|~)\s*(.*)/)
167 node = new Code(expression, options)
168
169 # Detect Haml node
170 else if expression.match(/^(%|#[^{]|\.|\!)(.*)/)
171 node = new Haml(expression, options)
172
173 # Everything else is a text node
174 else
175 node = new Text(expression, options)
176
177 options.parentNode?.addChild(node)
178
179 node
180
181 # Parse the given source and create the nested node
182 # structure. This parses the source code line be line, but
183 # looks ahead to find lines that should be merged into the current line.
184 # This is needed for splitting Haml attributes over several lines
185 # and also for the different types of filters.
186 #
187 # Parsing does not create an output, it creates the syntax tree in the
188 # compiler. To get the template, use `#render`.
189 #
190 # @param [String] source the HAML source code
191 #
192 parse: (source = '') ->
193 # Initialize line and indent markers
194 @lineNumber = @previousIndent = @tabSize = @currentBlockLevel = @previousBlockLevel = 0
195 @currentCodeBlockLevel = @previousCodeBlockLevel = 0
196
197 # Initialize nodes
198 @node = null
199 @stack = []
200 @root = @parentNode = new Node('', @getNodeOptions())
201
202 # Keep lines for look ahead
203 lines = source.split("\n")
204
205 # Parse source line by line
206 while (line = lines.shift()) isnt undefined
207
208 # After a filter, all lines are captured as text nodes until the end of the filer
209 if (@node instanceof Filter) and not @exitFilter
210
211 # Blank lines within a filter goes into the filter
212 if /^(\s)*$/.test(line)
213 @node.addChild(new Text('', @getNodeOptions({ parentNode: @node })))
214
215 # Detect if filter ends or if there is more text
216 else
217 result = line.match /^(\s*)(.*)/
218 ws = result[1]
219 expression = result[2]
220
221 # When on the same or less indent as the filter, exit and continue normal parsing
222 if @node.blockLevel >= (ws.length / 2)
223 @exitFilter = true
224 lines.unshift line
225 continue
226
227 # Get the filter text and remove filter node + indention whitespace
228 text = line.match ///^\s{#{ (@node.blockLevel * 2) + 2 }}(.*)///
229 @node.addChild(new Text(text[1], @getNodeOptions({ parentNode: @node }))) if text
230
231 # Normal line handling
232 else
233
234 # Clear exit filter flag
235 @exitFilter = false
236
237 # Get whitespace and Haml expressions
238 result = line.match /^(\s*)(.*)/
239 ws = result[1]
240 expression = result[2]
241
242 # Skip empty lines
243 continue if /^\s*$/.test(line)
244
245 # Look ahead for more attributes and add them to the current line
246 while /^[%.#].*[{(]/.test(expression) 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])
247
248 attributes = lines.shift()
249 expression = expression.replace(/(\s)+\|\s*$/, '')
250 expression += ' ' + attributes.match(/^\s*(.*?)(\s+\|\s*)?$/)[1]
251 @lineNumber++
252
253 # Look ahead for multi line |
254 if expression.match(/(\s)+\|\s*$/)
255 expression = expression.replace(/(\s)+\|\s*$/, ' ')
256
257 while lines[0]?.match(/(\s)+\|$/)
258 expression += lines.shift().match(/^(\s*)(.*)/)[2].replace(/(\s)+\|\s*$/, '')
259 @lineNumber++
260
261 @currentIndent = ws.length
262
263 # Update indention levels and set the current parent
264 if @indentChanged()
265 @updateTabSize()
266 @updateBlockLevel()
267 @updateParent()
268 @updateCodeBlockLevel(@parentNode)
269
270 # Create current node
271 @node = @nodeFactory(expression)
272
273 # Save previous indention levels
274 @previousBlockLevel = @currentBlockLevel
275 @previousIndent = @currentIndent
276
277 @lineNumber++
278
279 @evaluate(@root)
280
281 # Evaluate the parsed tree
282 #
283 # @param [Node] node the node to evaluate
284 #
285 evaluate: (node) ->
286 @evaluate(child) for child in node.children
287 node.evaluate()
288
289 # Render the parsed source code as CoffeeScript template.
290 #
291 # @param [String] templateName the name to register the template
292 # @param [String] namespace the namespace to register the template
293 #
294 render: (templateName, namespace = 'window.HAML') ->
295 switch @options.placement
296 when 'amd'
297 @renderAmd()
298 else
299 @renderGlobal templateName, namespace
300
301 # Render the parsed source code as CoffeeScript template wrapped in a
302 # define() statement for AMD. If the global modules list contains a module
303 # that starts with `hamlcoffee` and is assigned to the `hc` param, then
304 # all known helper functions will be taken from the `hamlcoffee` helper
305 # module.
306 #
307 # @private
308 # @return [String] the CoffeeScript template source code
309 #
310 renderAmd: ->
311 if /^hamlcoffee/.test @options.dependencies['hc']
312 @options.customHtmlEscape = 'hc.escape'
313 @options.customCleanValue = 'hc.cleanValue'
314 @options.customPreserve = 'hc.preserve'
315 @options.customFindAndPreserve = 'hc.findAndPreserve'
316 @options.customSurround = 'hc.surround'
317 @options.customSucceed = 'hc.succeed'
318 @options.customPrecede = 'hc.precede'
319 @options.customReference = 'hc.reference'
320
321 modules = []
322 params = []
323
324 for param, module of @options.dependencies
325 modules.push module
326 params.push param
327
328 template = indent(@precompile(), 4)
329
330 for param, module of @findDependencies(template)
331 modules.push module
332 params.push param
333
334 if modules.length isnt 0
335 modules = for m in modules
336 "'#{ m }'"
337
338 modules = "[#{ modules }], (#{ params.join(', ') })"
339 else
340 modules = ''
341
342 """
343 define #{ modules } ->
344 (context) ->
345 render = ->
346 \n#{ template }
347 render.call(context)
348 """
349
350 # Render the parsed source code as CoffeeScript template to a global
351 # window.HAML variable.
352 #
353 # @private
354 # @param [String] templateName the name to register the template
355 # @param [String] namespace the namespace to register the template
356 # @return [String] the CoffeeScript template source code
357 #
358 renderGlobal: (templateName, namespace = 'window.HAML') ->
359 template = ''
360
361 # Create parameter name from the filename, e.g. a file `users/new.hamlc`
362 # will create `window.HAML.user.new`
363 segments = "#{ namespace }.#{ templateName }".replace(/(\s|-)+/g, '_').split(/\./)
364 templateName = if @options.basename then segments.pop().split(/\/|\\/).pop() else segments.pop()
365 namespace = segments.shift()
366
367 # Create code for file and namespace creation
368 if segments.length isnt 0
369 for segment in segments
370 namespace += ".#{ segment }"
371 template += "#{ namespace } ?= {}\n"
372 else
373 template += "#{ namespace } ?= {}\n"
374
375 # Render the template and extend the scope with the context
376 if @options.extendScope
377 template += "#{ namespace }['#{ templateName }'] = (context) -> ( ->\n"
378 template += " `with (context || {}) {`\n"
379 template += "#{ indent(@precompile(), 1) }"
380 template += "`}`\n"
381 template += ").call(context)"
382
383 # Render the template without extending the scope
384 else
385 template += "#{ namespace }['#{ templateName }'] = (context) -> ( ->\n"
386 template += "#{ indent(@precompile(), 1) }"
387 template += ").call(context)"
388
389 template
390
391 # Pre-compiles the parsed source and generates
392 # the function source code.
393 #
394 # @return [String] the template function source code
395 #
396 precompile: ->
397 fn = ''
398 code = @createCode()
399
400 # Escape HTML entities
401 if code.indexOf('$e') isnt -1
402 if @options.customHtmlEscape
403 fn += "$e = #{ @options.customHtmlEscape }\n"
404 else
405 fn += """
406 $e = (text, escape) ->
407 "\#{ text }"
408 .replace(/&/g, '&amp;')
409 .replace(/</g, '&lt;')
410 .replace(/>/g, '&gt;')
411 .replace(/\'/g, '&#39;')
412 .replace(/\\//g, '&#47;')
413 .replace(/\"/g, '&quot;')\n
414 """
415
416 # Check values generated from template code
417 if code.indexOf('$c') isnt -1
418 if @options.customCleanValue
419 fn += "$c = #{ @options.customCleanValue }\n"
420 else
421 fn += "$c = (text) ->\n"
422 fn += " switch text\n"
423 fn += " when null, undefined then ''\n"
424 fn += " when true, false then '\u0093' + text\n"
425 fn += " else text\n"
426
427 # Preserve whitespace
428 if code.indexOf('$p') isnt -1 || code.indexOf('$fp') isnt -1
429 if @options.customPreserve
430 fn += "$p = #{ @options.customPreserve }\n"
431 else
432 fn += "$p = (text) -> text.replace /\\n/g, '&#x000A;'\n"
433
434 # Find whitespace sensitive tags and preserve
435 if code.indexOf('$fp') isnt -1
436 if @options.customFindAndPreserve
437 fn += "$fp = #{ @options.customFindAndPreserve }\n"
438 else
439 fn +=
440 """
441 $fp = (text) ->
442 text.replace /<(#{ @options.preserveTags.split(',').join('|') })>([^]*?)<\\/\\1>/g, (str, tag, content) ->
443 "<\#{ tag }>\#{ $p content }</\#{ tag }>"\n
444 """
445
446 # Surround helper
447 if code.indexOf('surround') isnt -1
448 if @options.customSurround
449 fn += "surround = (start, end, fn) => #{ @options.customSurround }.call(@, start, end, fn)\n"
450 else
451 fn += "surround = (start, end, fn) => start + fn.call(@)?.replace(/^\s+|\s+$/g, '') + end\n"
452
453 # Succeed helper
454 if code.indexOf('succeed') isnt -1
455 if @options.customSucceed
456 fn += "succeed = (start, end, fn) => #{ @options.customSucceed }.call(@, start, end, fn)\n"
457 else
458 fn += "succeed = (end, fn) => fn.call(@)?.replace(/\s+$/g, '') + end\n"
459
460 # Precede helper
461 if code.indexOf('precede') isnt -1
462 if @options.customPrecede
463 fn += "precede = (start, end, fn) => #{ @options.customPrecede }.call(@, start, end, fn)\n"
464 else
465 fn += "precede = (start, fn) => start + fn.call(@)?.replace(/^\s+/g, '')\n"
466
467 # Generate object reference
468 if code.indexOf('$r') isnt -1
469 if @options.customReference
470 fn += "$r = #{ @options.customReference }\n"
471 else
472 fn += """
473 $r = (object, prefix) ->
474 name = if prefix then prefix + '_' else ''
475
476 if typeof(object.hamlObjectRef) is 'function'
477 name += object.hamlObjectRef()
478 else
479 name += (object.constructor?.name || 'object').replace(/\W+/g, '_').replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase()
480
481 id = if typeof(object.to_key) is 'function'
482 object.to_key()
483 else if typeof(object.id) is 'function'
484 object.id()
485 else if object.id
486 object.id
487 else
488 object
489
490 result = "class='\#{ name }'"
491 result += " id='\#{ name }_\#{ id }'" if id\n
492 """
493
494 fn += "$o = []\n"
495 fn += "#{ code }\n"
496 fn += "return $o.join(\"\\n\")#{ @convertBooleans(code) }#{ @removeEmptyIDAndClass(code) }#{ @cleanupWhitespace(code) }\n"
497
498 # Create the CoffeeScript code for the template.
499 #
500 # This gets an array of all lines to be rendered in
501 # the correct sequence.
502 #
503 # @return [String] the CoffeeScript code
504 #
505 createCode: ->
506 code = []
507
508 @lines = []
509 @lines = @lines.concat(child.render()) for child in @root.children
510 @lines = @combineText(@lines)
511
512 @blockLevel = 0
513
514 for line in @lines
515 unless line is null
516 switch line.type
517
518 # Insert static HTML tag
519 when 'text'
520 code.push "#{ whitespace(line.cw) }#{ @getBuffer(@blockLevel) }.push \"#{ whitespace(line.hw) }#{ line.text }\""
521
522 # Insert code that is only evaluated and doesn't generate any output
523 when 'run'
524 if line.block isnt 'end'
525 code.push "#{ whitespace(line.cw) }#{ line.code }"
526 # End a block
527 else
528 code.push "#{ whitespace(line.cw) }#{ line.code.replace '$buffer', @getBuffer(@blockLevel) }"
529 @blockLevel -= 1
530
531 # Insert code that is evaluated and generates an output
532 when 'insert'
533 processors = ''
534 processors += '$fp ' if line.findAndPreserve
535 processors += '$p ' if line.preserve
536 processors += '$e ' if line.escape
537 processors += '$c ' if @options.cleanValue
538
539 code.push "#{ whitespace(line.cw) }#{ @getBuffer(@blockLevel) }.push \"#{ whitespace(line.hw) }\" + #{ processors }#{ line.code }"
540
541 # Initialize block output
542 if line.block is 'start'
543 @blockLevel += 1
544 code.push "#{ whitespace(line.cw + 1) }#{ @getBuffer(@blockLevel) } = []"
545
546 code.join '\n'
547
548 # Get the code buffer identifer
549 #
550 # @param [Number] level the block indention level
551 #
552 getBuffer: (level) ->
553 if level > 0 then "$o#{ level }" else '$o'
554
555 # Optimize the lines to be rendered by combining subsequent text
556 # nodes that are on the same code line indention into a single line.
557 #
558 # @param [Array<Object>] lines the code lines
559 # @return [Array<Object>] the optimized lines
560 #
561 combineText: (lines) ->
562 combined = []
563
564 while (line = lines.shift()) isnt undefined
565 if line.type is 'text'
566 while lines[0] and lines[0].type is 'text' and line.cw is lines[0].cw
567 nextLine = lines.shift()
568 line.text += "\\n#{ whitespace(nextLine.hw) }#{ nextLine.text }"
569
570 combined.push line
571
572 combined
573
574 # Adds a boolean convert logic that changes boolean attribute
575 # values depending on the output format. This works only when
576 # the clean value function add a hint marker (\u0093) to each
577 # boolean value, so that the conversion logic can disinguish
578 # between dynamic, real boolean values and string values like
579 # 'false' and 'true' or compile time attributes.
580 #
581 # With the XHTML format, an attribute `checked='true'` will be
582 # converted to `checked='checked'` and `checked='false'` will
583 # be completely removed.
584 #
585 # With the HTML4 and HTML5 format, an attribute `checked='true'`
586 # will be converted to `checked` and `checked='false'` will
587 # be completely removed.
588 #
589 # @param [String] code the CoffeeScript template code
590 # @return [String] the clean up whitespace code if necessary
591 #
592 convertBooleans: (code) ->
593 if code.indexOf('$c') isnt -1
594 if @options.format is 'xhtml'
595 '.replace(/\\s(\\w+)=\'\u0093true\'/mg, " $1=\'$1\'").replace(/\\s(\\w+)=\'\u0093false\'/mg, \'\')'
596 else
597 '.replace(/\\s(\\w+)=\'\u0093true\'/mg, \' $1\').replace(/\\s(\\w+)=\'\u0093false\'/mg, \'\')'
598 else
599 ''
600
601 # Remove empty ID and class attribute from the
602 # final template. In case of the ID this is required
603 # in order to generate valid HTML.
604 #
605 # @param [String] code the CoffeeScript template code
606 # @return [String] the template code with the code added
607 #
608 removeEmptyIDAndClass: (code) ->
609 if code.indexOf('id=') isnt -1 || code.indexOf('class=') isnt -1
610 '.replace(/\\s(?:id|class)=([\'"])(\\1)/mg, "")'
611 else
612 ''
613
614 # Adds whitespace cleanup function when needed by the
615 # template. The cleanup must be done AFTER the template
616 # has been rendered.
617 #
618 # The detection is based on hidden unicode characters that
619 # are placed as marker into the template:
620 #
621 # * `\u0091` Cleanup surrounding whitespace to the left
622 # * `\u0092` Cleanup surrounding whitespace to the right
623 #
624 # @param [String] code the CoffeeScript template code
625 # @return [String] the clean up whitespace code if necessary
626 #
627 cleanupWhitespace: (code) ->
628 if /\u0091|\u0092/.test code
629 ".replace(/[\\s\\n]*\\u0091/mg, '').replace(/\\u0092[\\s\\n]*/mg, '')"
630 else
631 ''
632
633 # Searches for AMD require statements to find
634 # all template dependencies.
635 #
636 # @example CST source code
637 # $o.push "" + $c require('assets/templates/test')() => { test: 'assets/templates/test' }
638 #
639 # @param [String] code the CoffeeScript template source code
640 # @return [Object] the module dependencies
641 #
642 findDependencies: (code) ->
643 requireRegexp = /require(?:\s+|\()(['"])(.+?)(\1)\)?/gm
644 dependencies = {}
645
646 while match = requireRegexp.exec code
647 module = match[2]
648 name = module.split('/').pop()
649
650 dependencies[name] = module
651
652 dependencies