UNPKG

22.4 kBtext/coffeescriptView Raw
1Node = require('./node')
2
3{escapeQuotes} = require('../util/text')
4
5# HAML node that contains Haml a haml tag that can have attributes
6# and a text or code assignment. There are shortcuts for id and class
7# generation and some special logic for merging attributes into existing
8# ids and classes.
9#
10# @example Haml tag
11# %footer => <footer></footer>
12#
13# @example Haml id
14# #content => <div id='content'></div>
15# %span#status{ :id => @user.status } => <span id='status_#{ @user.status }'></span>
16#
17# @example Haml classes
18# .hidden => <div class='hidden'></div>
19# %span.large.hidden => <span class='large hidden'></span>
20# .large{ :class => @user.role } => <div class='large #{ @user.role }'></div>
21#
22# Haml HTML attributes are very limited and allows only simple string
23# (with interpolation) or variable assignment to an attribute.
24#
25# @example Haml HTML attributes
26# %p(class='hidden') => <p class='hidden'><p>
27# #account(class=@status) => <div id='account' class='#{ status }'></div>
28# .logout(title="Logout #{ user.name }") => <div class='logout' title='Logout #{ user.name }'></div>
29#
30# Ruby HTML attributes are more powerful and allows in addition to the
31# HTML attributes function calls:
32#
33# @example Haml Ruby attributes
34# %p{ :class => App.user.get('role') } => <p class='#{ App.user.get('role') }'></p>
35#
36module.exports = class Haml extends Node
37
38 # Evaluate the node content and store the opener tag
39 # and the closer tag if applicable.
40 #
41 evaluate: ->
42 tokens = @parseExpression(@expression)
43
44 # Evaluate Haml doctype
45 if tokens.doctype
46 @opener = @markText "#{ escapeQuotes(@buildDocType(tokens.doctype)) }"
47
48 # Evaluate Haml tag
49 else
50 # Create a Haml node that can contain child nodes
51 if @isNotSelfClosing(tokens.tag)
52
53 prefix = @buildHtmlTagPrefix(tokens)
54
55 # Add Haml tag that contains a code assignment will be closed immediately
56 if tokens.assignment
57
58 match = tokens.assignment.match /^(=|!=|&=|~)\s*(.*)$/
59
60 identifier = match[1]
61 assignment = match[2]
62
63 if identifier is '~'
64 code = "\#{$fp #{ assignment } }"
65
66 # Code block with escaped code block, either `=` in escaped mode or `&=`
67 else if identifier is '&=' or (identifier is '=' and @escapeHtml)
68 if @preserve
69 if @cleanValue
70 code = "\#{ $p($e($c(#{ assignment }))) }"
71 else
72 code = "\#{ $p($e(#{ assignment })) }"
73 else
74 if @cleanValue
75 code = "\#{ $e($c(#{ assignment })) }"
76 else
77 code = "\#{ $e(#{ assignment }) }"
78
79 # Code block with unescaped output, either with `!=` or escaped mode to false
80 else if identifier is '!=' or (identifier is '=' and not @escapeHtml)
81 if @preserve
82 if @cleanValue
83 code = "\#{ $p($c(#{ assignment })) }"
84 else
85 code = "\#{ $p(#{ assignment }) }"
86 else
87 if @cleanValue
88 code = "\#{ $c(#{ assignment }) }"
89 else
90 code = "\#{ #{ assignment } }"
91
92 @opener = @markText "#{ prefix }>#{ code }"
93 @closer = @markText "</#{ tokens.tag }>"
94
95 # A Haml tag that contains an inline text
96 else if tokens.text
97 @opener = @markText "#{ prefix }>#{ tokens.text }"
98 @closer = @markText "</#{ tokens.tag }>"
99
100 # A Haml tag that can get more child nodes
101 else
102 @opener = @markText prefix + '>'
103 @closer = @markText "</#{ tokens.tag}>"
104
105 # Create a self closing tag that depends on the format `<br>` or `<br/>`
106 else
107 tokens.tag = tokens.tag.replace /\/$/, ''
108 prefix = @buildHtmlTagPrefix(tokens)
109 @opener = @markText "#{ prefix }#{ if @format is 'xhtml' then ' /' else '' }>"
110
111 # Parses the expression and detect the tag, attributes
112 # and any assignment. In addition class and id cleanup
113 # is performed according the the Haml spec:
114 #
115 # * Classes are merged together
116 # * When multiple ids are provided, the last one is taken,
117 # except they are defined in shortcut notation and attribute
118 # notation. In this case, they will be combined, separated by
119 # underscore.
120 #
121 # @example Id merging
122 # #user{ :id => @user.id } => <div id='user_#{ @user.id }'></div>
123 #
124 # @param [String] exp the HAML expression
125 # @return [Object] the parsed tag and options tokens
126 #
127 parseExpression: (exp) ->
128 tag = @parseTag(exp)
129
130 @preserve = true if @preserveTags.indexOf(tag.tag) isnt -1
131
132 id = @interpolateCodeAttribute(tag.ids?.pop(), true)
133 classes = tag.classes
134 attributes = {}
135
136 # Clean attributes
137 if tag.attributes
138 for key, value of tag.attributes
139 if key is 'id'
140 if id
141 # Merge attribute id into existing id
142 id += '_' + @interpolateCodeAttribute(value, true)
143 else
144 # Push id from attribute
145 id = @interpolateCodeAttribute(value, true)
146
147 # Merge classes
148 else if key is 'class'
149 classes or= []
150 classes.push value
151
152 # Add to normal attributes
153 else
154 attributes[key] = value
155
156 {
157 doctype : tag.doctype
158 tag : tag.tag
159 id : id
160 classes : classes
161 text : escapeQuotes(tag.text)
162 attributes : attributes
163 assignment : tag.assignment
164 reference : tag.reference
165 }
166
167 # Parse a tag line. This recognizes DocType tags `!!!` and
168 # HAML tags like `#id.class text`.
169 #
170 # It also parses the code assignment `=`, `}=` and `)=` or
171 # inline text and the whitespace removal markers `<` and `>`.
172 #
173 # It detects an object reference `[` and attributes `(` / `{`.
174 #
175 # @param [String] exp the HAML expression
176 # @return [Object] the parsed tag tokens
177 #
178 parseTag: (exp) ->
179 try
180 doctype = exp.match(/^(\!{3}.*)/)?[1]
181 return { doctype: doctype } if doctype
182
183 # Match the haml tag %a, .name, etc.
184 haml = exp.match(/^((?:[#%\.][a-z0-9_:\-]*[\/]?)+)/i)[0]
185 rest = exp.substring(haml.length)
186
187 # The haml tag has attributes
188 if rest.match /^[{([]/
189
190 reference = ''
191 htmlAttributes = ''
192 rubyAttributes = ''
193
194 for start in ['[', '{', '(', '[', '{', '(']
195 if start is rest[0]
196 # Get used attribute surround end character
197 end = switch start
198 when '{' then '}'
199 when '(' then ')'
200 when '[' then ']'
201
202 # Extract attributes by keeping track of brace/parenthesis level
203 level = 0
204 for pos in [0..rest.length]
205 ch = rest[pos]
206
207 # Increase level when a nested brace/parenthesis is started
208 level += 1 if ch is start
209
210 # Decrease level when a nested brace/parenthesis is end or exit when on the last level
211 if ch is end
212 if level is 1 then break else level -= 1
213
214 # Extract result
215 switch start
216 when '{'
217 rubyAttributes += rest.substring(0, pos + 1)
218 rest = rest.substring(pos + 1)
219 when '('
220 htmlAttributes += rest.substring(0, pos + 1)
221 rest = rest.substring(pos + 1)
222 when '['
223 reference = rest.substring(1, pos)
224 rest = rest.substring(pos + 1)
225
226 assignment = rest || ''
227
228 # No attributes defined
229 else
230 reference = ''
231 htmlAttributes = ''
232 rubyAttributes = ''
233 assignment = rest
234
235 # Merge HTML and Ruby style attributes
236 attributes = {}
237 for attr in [@parseAttributes(htmlAttributes), @parseAttributes(rubyAttributes)]
238 attributes[key] = val for key, val of attr
239
240 # Extract whitespace removal
241 if whitespace = assignment.match(/^[<>]{0,2}/)?[0]
242 assignment = assignment.substring(whitespace.length)
243
244 # Remove the delimiter space from the assignment
245 assignment = assignment.substring(1) if assignment[0] is ' '
246
247 # Process inline text or assignment
248 if assignment and not assignment.match(/^(=|!=|&=|~)/)
249 text = assignment.replace(/^ /, '')
250 assignment = undefined
251
252 # Set whitespace removal markers
253 if whitespace
254 @wsRemoval.around = true if whitespace.indexOf('>') isnt -1
255
256 if whitespace.indexOf('<') isnt -1
257 @wsRemoval.inside = true
258 @preserve = true
259
260 # Extracts tag name, id and classes
261 tag = haml.match(/\%([a-z_\-][a-z0-9_:\-]*[\/]?)/i)
262 ids = haml.match(/\#([a-z_\-][a-z0-9_\-]*)/gi)
263 classes = haml.match(/\.([a-z0-9_\-]*)/gi)
264
265 {
266 tag : if tag then tag[1] else 'div'
267 ids : ("'#{ id.substr(1) }'" for id in ids) if ids
268 classes : ("'#{ klass.substr(1) }'" for klass in classes) if classes
269 attributes : attributes
270 assignment : assignment
271 reference : reference
272 text : text
273 }
274
275 catch error
276 throw new Error("Unable to parse tag from #{ exp }: #{ error }")
277
278 # Parse attributes either in Ruby style `%tag{ :attr => 'value' }`
279 # or HTML style `%tag(attr='value)`. Both styles can be mixed:
280 # `%tag(attr='value){ :attr => 'value' }`.
281 #
282 # This takes also care of proper attribute interpolation, unwrapping
283 # quoted keys and value, e.g. `'a' => 'hello'` becomes `a => hello`.
284 #
285 # @param [String] exp the HAML expression
286 # @return [Object] the parsed attributes
287 #
288 parseAttributes: (exp) ->
289 attributes = {}
290 return attributes if exp is undefined
291
292 type = exp.substring(0, 1)
293
294 # Mark key separator characters within quoted values, so they aren't recognized as keys.
295 exp = exp.replace /(=|:|=>)\s*('([^\\']|\\\\|\\')*'|"([^\\"]|\\\\|\\")*")/g, (match, type, value) ->
296 type + value?.replace /(:|=|=>)/g, '\u0090$1'
297
298 # Mark key separator characters within parenthesis, so they aren't recognized as keys.
299 level = 0
300 start = 0
301 markers = []
302
303 if type is '('
304 startPos = 1
305 endPos = exp.length - 1
306 else
307 startPos = 0
308 endPos = exp.length
309
310 for pos in [startPos...endPos]
311 ch = exp[pos]
312
313 # Increase level when a parenthesis is started
314 if ch is '('
315 level += 1
316 start = pos if level is 1
317
318 # Decrease level when a parenthesis is end
319 if ch is ')'
320 if level is 1
321 markers.push({ start: start, end: pos }) if start isnt 0 and pos - start isnt 1
322 else
323 level -= 1
324
325 for marker in markers.reverse()
326 exp = exp.substring(0, marker.start) + exp.substring(marker.start, marker.end).replace(/(:|=|=>)/g, '\u0090$1') + exp.substring(marker.end)
327
328 # Detect the used key type
329 switch type
330 when '('
331 # HTML attribute keys
332 keys = ///
333 \(\s*([-\w]+[\w:-]*\w?)\s*=
334 |
335 \s+([-\w]+[\w:-]*\w?)\s*=
336 |
337 \(\s*('\w+[\w:-]*\w?')\s*=
338 |
339 \s+('\w+[\w:-]*\w?')\s*=
340 |
341 \(\s*("\w+[\w:-]*\w?")\s*=
342 |
343 \s+("\w+[\w:-]*\w?")\s*=
344 ///g
345
346 when '{'
347 # Ruby attribute keys
348 keys = ///
349 [{,]\s*(\w+[\w:-]*\w?)\s*:
350 |
351 [{,]\s*('[-\w]+[\w:-]*\w?')\s*:
352 |
353 [{,]\s*("[-\w]+[\w:-]*\w?")\s*:
354 |
355 [{,]\s*:(\w+[\w:-]*\w?)\s*=>
356 |
357 [{,]\s*:?'([-\w]+[\w:-]*\w?)'\s*=>
358 |
359 [{,]\s*:?"([-\w]+[\w:-]*\w?)"\s*=>
360 ///g
361
362 # Split into key value pairs
363 pairs = exp.split(keys).filter(Boolean)
364
365 inDataAttribute = false
366 hasDataAttribute = false
367
368 # Process the pairs in a group of two
369 while pairs.length
370 keyValue = pairs.splice 0, 2
371
372 # Just a single attribute without value
373 if keyValue.length is 1
374 attr = keyValue[0].replace(/^[\s({]+|[\s)}]+$/g, '')
375 attributes[attr] = 'true'
376
377 # Attribute with value or multiple attributes
378 else
379 # Trim key and remove preceding colon and remove markers
380 key = keyValue[0]?.replace(/^\s+|\s+$/g, '').replace(/^:/, '')
381 key = quoted[2] if quoted = key.match /^("|')(.*)\1$/
382
383 # Trim value, remove succeeding comma and remove markers
384 value = keyValue[1]?.replace(/^\s+|[\s,]+$/g, '').replace(/\u0090/g, '')
385
386 if key is 'data' and !value
387 inDataAttribute = true
388 hasDataAttribute = true
389
390 else if key and value
391 if inDataAttribute
392 key = if @hyphenateDataAttrs then "data-#{ key.replace('_', '-') }" else "data-#{ key }"
393 inDataAttribute = false if /}\s*$/.test value
394
395 switch type
396 when '('
397 value = value.replace(/^\s+|[\s)]+$/g, '')
398
399 # Detect attributes without value as value suffix
400 quote = /^(['"])/.exec(value)?[1]
401 pos = value.lastIndexOf quote
402
403 if pos > 1
404 for attr in value.substring(pos + 1).split ' '
405 attributes[attr] = 'true' if attr
406
407 value = value.substring(0, pos + 1)
408
409
410 attributes[key] = value
411 when '{'
412 attributes[key] = value.replace(/^\s+|[\s}]+$/g, '')
413
414 delete attributes['data'] if hasDataAttribute
415
416 attributes
417
418 # Build the HTML tag prefix by concatenating all the
419 # tag information together. The result is an unfinished
420 # html tag that must be further processed:
421 #
422 # @example Prefix tag
423 # <a id='id' class='class' attr='value'
424 #
425 # The Haml spec sorts the `class` names, even when they
426 # contain interpolated classes. This is supported by
427 # sorting classes at template render time.
428 #
429 # If both an object reference and an id or class attribute is defined,
430 # then the attribute will be ignored.
431 #
432 # @example Template render time sorting
433 # <p class='#{ [@user.name(), 'show'].sort().join(' ') }'>
434 #
435 # @param [Object] tokens all parsed tag tokens
436 # @return [String] the tag prefix
437 #
438 buildHtmlTagPrefix: (tokens) ->
439 tagParts = ["<#{ tokens.tag }"]
440
441 # Set tag classes
442 if tokens.classes
443
444 hasDynamicClass = false
445
446 # Prepare static and dynamic class names
447 classList = for name in tokens.classes
448 name = @interpolateCodeAttribute(name, true)
449 hasDynamicClass = true if name.indexOf('#{') isnt -1
450 name
451
452 # Render time classes
453 if hasDynamicClass && classList.length > 1
454 classes = '#{ ['
455 classes += "#{ @quoteAndEscapeAttributeValue(klass, true) }," for klass in classList
456 classes = classes.substring(0, classes.length - 1) + '].sort().join(\' \').replace(/^\\s+|\\s+$/g, \'\') }'
457
458 # Compile time classes
459 else
460 classes = classList.sort().join ' '
461
462 tagParts.push "class='#{ classes }'"
463
464 # Set tag id
465 tagParts.push "id='#{ tokens.id }'" if tokens.id
466
467 # Add id from object reference
468 if tokens.reference
469 if tokens.attributes
470 delete tokens.attributes['class']
471 delete tokens.attributes['id']
472
473 tagParts.push "\#{$r(" + tokens.reference + ")}"
474
475 # Construct tag attributes
476 if tokens.attributes
477 for key, value of tokens.attributes
478
479 # Boolean attribute logic
480 if value is 'true' or value is 'false'
481
482 # Only show true values
483 if value is 'true'
484 if @format is 'html5'
485 tagParts.push "#{ key }"
486 else
487 tagParts.push "#{ key }=#{ @quoteAndEscapeAttributeValue(key) }"
488
489 # Anything but booleans
490 else
491 tagParts.push "#{ key }=#{ @quoteAndEscapeAttributeValue(@interpolateCodeAttribute(value)) }"
492
493 tagParts.join(' ')
494
495 # Wrap plain attributes into an interpolation for execution.
496 # In addition wrap it into escaping and cleaning function,
497 # depending on the options.
498 #
499 # @param [String] text the possible code
500 # @param [Boolean] unwrap unwrap static text from quotes
501 # @return [String] the text of the wrapped code
502 #
503 interpolateCodeAttribute: (text, unwrap = false) ->
504 return unless text
505
506 if not text.match /^("|').*\1$/
507 if @escapeAttributes
508 if @cleanValue
509 text = '#{ $e($c(' + text + ')) }'
510 else
511 text = '#{ $e(' + text + ') }'
512 else
513 if @cleanValue
514 text = '#{ $c(' + text + ') }'
515 else
516 text = '#{ (' + text + ') }'
517
518 if unwrap
519 text = quoted[2] if quoted = text.match /^("|')(.*)\1$/
520
521 text
522
523 # Quote the attribute value, depending on its
524 # content. If the attribute contains an interpolation,
525 # each interpolation will be cleaned and/or escaped,
526 # depending on the compiler options.
527 #
528 # @param [String] value the without start and end quote
529 # @param [String] code if we are in a code block
530 # @return [String] the quoted value
531 #
532 quoteAndEscapeAttributeValue: (value, code = false) ->
533 return unless value
534
535 value = quoted[2] if quoted = value.match /^("|')(.*)\1$/
536 tokens = @splitInterpolations value
537
538 hasSingleQuotes = false
539 hasDoubleQuotes = false
540 hasInterpolation = false
541
542 # Analyse existing quotes and escape/clean interpolations
543 tokens = for token in tokens
544 if token[0..1] is '#{'
545 # Skip interpolated code attributes
546 if token.indexOf('$e') is -1 and token.indexOf('$c') is -1
547 if @escapeAttributes
548 if @cleanValue
549 token = '#{ $e($c(' + token[2...-1] + ')) }'
550 else
551 token = '#{ $e(' + token[2...-1] + ') }'
552 else
553 if @cleanValue
554 token = '#{ $c(' + token[2...-1] + ') }'
555
556 hasInterpolation = true
557 else
558 hasSingleQuotes = token.indexOf("'") isnt -1 unless hasSingleQuotes
559 hasDoubleQuotes = token.indexOf('"') isnt -1 unless hasDoubleQuotes
560
561 token
562
563 if code
564 if hasInterpolation
565 result = "\"#{ tokens.join('') }\""
566 else
567 result = "'#{ tokens.join('') }'"
568
569 else
570 # Without any qoutes, wrap the value in single quotes
571 if not hasDoubleQuotes and not hasSingleQuotes
572 result = "'#{ tokens.join('') }'"
573
574 # With only single quotes, wrap the value in double quotes
575 if hasSingleQuotes and not hasDoubleQuotes
576 result = "\\\"#{ tokens.join('') }\\\""
577
578 # With only double quotes, wrap the value in single quotes and escape the double quotes
579 if hasDoubleQuotes and not hasSingleQuotes
580 escaped = for token in tokens
581 escapeQuotes(token)
582 result = "'#{ escaped.join('') }'"
583
584 # With both type of quotes, wrap the value in single quotes, escape the double quotes and
585 # convert the single quotes to it's entity representation
586 if hasSingleQuotes and hasDoubleQuotes
587 escaped = for token in tokens
588 escapeQuotes(token).replace(/'/g, '&#39;')
589 result = "'#{ escaped.join('') }'"
590
591 result
592
593 # Split expression by its interpolations.
594 #
595 # @example
596 # 'Hello #{ "#{ soso({}) }" } Interpol') => ["Hello ", "#{ "#{ soso({}) }" }", " Interpol"]
597 # 'Hello #{ "#{ soso }" } Interpol') => ["Hello ", "#{ "#{ soso }" }", " Interpol"]
598 # 'Hello #{ int } Interpol') => ["Hello ", "#{ int }", " Interpol"]
599 # 'Hello Interpol') => ["Hello Interpol"]
600 # '#{ int } Interpol') => ["#{ int }", " Interpol"]
601 # 'Hello #{ int }') => ["Hello ", "#{ int }"]
602 # '#{ int }') => ["#{ int }"]
603 #
604 # @param [String] value the attribute value
605 # @return [Array<String>] the splitted string
606 #
607 splitInterpolations: (value) ->
608 level = 0
609 start = 0
610 tokens = []
611
612 for pos in [0...value.length]
613 ch = value[pos]
614 ch2 = value[pos..pos + 1]
615
616 if ch is '{'
617 level += 1
618
619 if ch2 is '#{' and level is 0
620 tokens.push(value[start...pos])
621 start = pos
622
623 if ch is '}'
624 level -= 1
625
626 if level is 0
627 tokens.push(value[start..pos])
628 start = pos + 1
629
630 tokens.push(value[start...value.length])
631
632 tokens.filter(Boolean)
633
634 # Build the DocType string depending on the `!!!` token
635 # and the currently used HTML format.
636 #
637 # @param [String] doctype the HAML doctype
638 # @return [String] the HTML doctype
639 #
640 buildDocType: (doctype) ->
641 switch "#{ @format } #{ doctype }"
642 when 'xhtml !!! XML' then '<?xml version=\'1.0\' encoding=\'utf-8\' ?>'
643 when 'xhtml !!!' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'
644 when 'xhtml !!! 1.1' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">'
645 when 'xhtml !!! mobile' then '<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">'
646 when 'xhtml !!! basic' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">'
647 when 'xhtml !!! frameset' then '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">'
648 when 'xhtml !!! 5', 'html5 !!!' then '<!DOCTYPE html>'
649 when 'html5 !!! XML', 'html4 !!! XML' then ''
650 when 'html4 !!!' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'
651 when 'html4 !!! frameset' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">'
652 when 'html4 !!! strict' then '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">'
653
654 # Test if the given tag is a non-self enclosing tag, by
655 # matching against a fixed tag list or parse for the self
656 # closing slash `/` at the end.
657 #
658 # @param [String] tag the tag name without brackets
659 # @return [Boolean] true when a non self closing tag
660 #
661 isNotSelfClosing: (tag) ->
662 @selfCloseTags.indexOf(tag) == -1 && !tag.match(/\/$/)