diff --git a/benchmark.coffee b/benchmark.coffee index 9f2f61e..2d5959a 100644 --- a/benchmark.coffee +++ b/benchmark.coffee @@ -1,3 +1,5 @@ +#!/usr/bin/env coffee + coffeekup = require './src/coffeekup' jade = require 'jade' ejs = require 'ejs' @@ -63,6 +65,8 @@ coffeekup_string_template = """ coffeekup_compiled_template = coffeekup.compile coffeekup_template +coffeekup_optimized_template = coffeekup.compile coffeekup_template, optimize: yes + jade_template = ''' !!! 5 html(lang="en") @@ -153,6 +157,8 @@ eco_template = ''' ''' +eco_compiled_template = eco.compile eco_template + haml_template = ''' !!! 5 %html{lang: "en"} @@ -185,23 +191,25 @@ benchmark = (title, code) -> code() log "#{title}: #{new Date - start} ms" -@run = -> - benchmark 'CoffeeKup (precompiled)', -> coffeekup_compiled_template data - benchmark 'Jade (precompiled)', -> jade_compiled_template data - benchmark 'haml-js (precompiled)', -> haml_template_compiled data - benchmark 'Eco', -> eco.render eco_template, data - console.log '\n' +benchmark 'CoffeeKup (precompiled)', -> coffeekup_compiled_template data +benchmark 'CoffeeKup (precompiled, optimized)', -> coffeekup_optimized_template data +benchmark 'Jade (precompiled)', -> jade_compiled_template data +benchmark 'haml-js (precompiled)', -> haml_template_compiled data +benchmark 'Eco (precompiled)', -> eco_compiled_template data + +console.log '\n' - benchmark 'CoffeeKup (function, cache on)', -> coffeekup.render coffeekup_template, data, cache: on - benchmark 'CoffeeKup (string, cache on)', -> coffeekup.render coffeekup_string_template, data, cache: on - benchmark 'Jade (cache on)', -> jade.render jade_template, locals: data, cache: on, filename: 'test' - benchmark 'ejs (cache on)', -> ejs.render ejs_template, locals: data, cache: on, filename: 'test' +benchmark 'CoffeeKup (function, cache on)', -> coffeekup.render coffeekup_template, data, cache: on +benchmark 'CoffeeKup (string, cache on)', -> coffeekup.render coffeekup_string_template, data, cache: on +#benchmark 'Jade (cache on)', -> jade.render jade_template, locals: data, cache: on, filename: 'test' +benchmark 'ejs (cache on)', -> ejs.render ejs_template, locals: data, cache: on, filename: 'test' +benchmark 'Eco', -> eco.render eco_template, data - console.log '\n' +console.log '\n' - benchmark 'CoffeeKup (function, cache off)', -> coffeekup.render coffeekup_template, data - benchmark 'CoffeeKup (string, cache off)', -> coffeekup.render coffeekup_string_template, data, cache: off - benchmark 'Jade (cache off)', -> jade.render jade_template, locals: data - benchmark 'haml-js', -> haml.render haml_template, locals: data - benchmark 'ejs (cache off)', -> ejs.render ejs_template, locals: data +benchmark 'CoffeeKup (function, cache off)', -> coffeekup.render coffeekup_template, data, cache: off +benchmark 'CoffeeKup (string, cache off)', -> coffeekup.render coffeekup_string_template, data, cache: off +#benchmark 'Jade (cache off)', -> jade.render jade_template, locals: data +benchmark 'haml-js', -> haml.render haml_template, locals: data +benchmark 'ejs (cache off)', -> ejs.render ejs_template, locals: data diff --git a/bin/coffeekup b/bin/coffeekup index 1134b63..6998b0b 100755 --- a/bin/coffeekup +++ b/bin/coffeekup @@ -1,7 +1,3 @@ -#!/usr/bin/env node +#!/usr/bin/env coffee -var path = require('path') -var fs = require('fs') -var lib = path.join(path.dirname(fs.realpathSync(__filename)), '../lib') - -require(lib + '/cli').run() +require(__dirname + '/../src/cli').run() diff --git a/package.json b/package.json index 3febb51..67e905c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "devDependencies": {"jade": "0.13.0", "eco": "1.1.0-rc-1", "ejs": "0.4.3", "haml": "0.4.2"}, "keywords": ["template", "view", "coffeescript"], "bin": "./bin/coffeekup", - "main": "./lib/coffeekup", + "main": "./src/coffeekup", "engines": {"node": ">= 0.4.7"}, "contributors": [ "Luis Pedro Coelho ", diff --git a/src/coffeekup.coffee b/src/coffeekup.coffee index 339bf89..90f2468 100644 --- a/src/coffeekup.coffee +++ b/src/coffeekup.coffee @@ -15,6 +15,8 @@ if window? else coffeekup = exports coffee = require 'coffee-script' + compiler = require __dirname + '/compiler' + compiler.setup coffeekup coffeekup.version = '0.3.1edge' @@ -294,6 +296,11 @@ coffeekup.compile = (template, options = {}) -> hardcoded_locals += "var #{k} = function(){return (#{v}).apply(data, arguments);};" else hardcoded_locals += "var #{k} = #{JSON.stringify v};" + # If `optimize` is set on the options hash, use uglify-js to parse the + # template function's code and optimize it using static analysis. + if options.optimize and compiler? + return compiler.compile template, hardcoded_locals, options + # Add a function for each tag this template references. We don't want to have # all hundred-odd tags wasting space in the compiled function. tag_functions = '' @@ -338,6 +345,10 @@ coffeekup.render = (template, data = {}, options = {}) -> data[k] = v for k, v of options data.cache ?= off + # Do not optimize templates if the cache is disabled, as it will slow + # everything down considerably. + if data.optimize and not data.cache then data.optimize = no + if data.cache and cache[template]? then tpl = cache[template] else if data.cache then tpl = cache[template] = coffeekup.compile(template, data) else tpl = coffeekup.compile(template, data) @@ -368,4 +379,4 @@ unless window? return -> try tpl arguments... - catch e then throw new TemplateError "Error rendering #{data.filename}: #{e.message}" \ No newline at end of file + catch e then throw new TemplateError "Error rendering #{data.filename}: #{e.message}" diff --git a/src/compiler.coffee b/src/compiler.coffee new file mode 100644 index 0000000..0273250 --- /dev/null +++ b/src/compiler.coffee @@ -0,0 +1,340 @@ +coffee = require 'coffee-script' +{uglify, parser} = require 'uglify-js' +coffeekup = null + +# Call this from the main script so that the compiler module can have access to +# coffeekup exports (node does not allow circular imports). +exports.setup = (ck) -> + coffeekup = ck + +skeleton = ''' + var __ck = { + buffer: '' + }; + var text = function(txt) { + if (typeof txt === 'string' || txt instanceof String) { + __ck.buffer += txt; + } else if (typeof txt === 'number' || txt instanceof Number) { + __ck.buffer += String(txt); + } + }; + var h = function(txt) { + var escaped; + if (typeof txt === 'string' || txt instanceof String) { + escaped = txt.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } else { + escaped = txt; + } + return escaped; + }; + var yield = function(f) { + var temp_buffer = ''; + var old_buffer = __ck.buffer; + __ck.buffer = temp_buffer; + f(); + temp_buffer = __ck.buffer; + __ck.buffer = old_buffer; + return temp_buffer; + }; + +''' + +call_bound_func = (func) -> + # function(){ }.call(data) + return ['call', ['dot', func, 'call'], + [['name', 'data']]] + +# Represents compiled javascript code to be written to the template function. +class Code + constructor: (parent) -> + @parent = parent + @nodes = [] + @line = '' + + # Returns the ast node for `text();` + call: (arg) -> + return ['stat', ['call', ['name', 'text'], [arg]]] + + # Add `str` to the current line to be written + append: (str) -> + if @block? + @block.append str + else + @line += str + + # Flush the buffered line to the array of nodes + flush: -> + if @block? + @block.flush() + else + @nodes.push @call ['string', @line] + @line = '' + + # Wrap subsequent calls to `text()` in an if block + open_if: (condition) -> + @flush() + if @block? + @block.open_if condition + else + @block = new Code() + @block.condition = condition + + # Close an if block + close_if: -> + @flush() + if @block.block? + @block.close_if() + else + @nodes.push ['if', @block.condition, ['block', @block.nodes]] + delete @block + + # Wrap an ast node in a call to `text()` and add it to the array of nodes + push: (node) -> + @flush() + if @block? + @block.push node + else + @nodes.push @call node + + # If the parent statement ends with a semicolon and is not an argument + # to a function, return the statements as separate nodes. Otherwise wrap them + # in an anonymous function bound to the `data` object. + get_nodes: -> + @flush() + + if @parent[0] is 'stat' + return ['splice', @nodes] + + return call_bound_func([ + 'function' + null # Anonymous function + [] # Takes no arguments + @nodes + ]) + + +exports.compile = (source, hardcoded_locals, options) -> + + escape = (node) -> + if options.autoescape + # h() + return ['call', ['name', 'h'], [node]] + return node + + ast = parser.parse hardcoded_locals + "(#{source}).call(data);" + w = uglify.ast_walker() + ast = w.with_walkers + call: (expr, args) -> + name = expr[1] + + if name is 'doctype' + code = new Code w.parent() + if args.length > 0 + doctype = String(args[0][1]) + if doctype of coffeekup.doctypes + code.append coffeekup.doctypes[doctype] + else + throw new Error 'Invalid doctype' + else + code.append coffeekup.doctypes.default + return code.get_nodes() + + else if name is 'comment' + comment = args[0] + code = new Code w.parent() + if comment[0] is 'string' + code.append "" + else + code.append '' + return code.get_nodes() + + else if name is 'ie' + [condition, contents] = args + code = new Code w.parent() + if condition[0] is 'string' + code.append "' + return code.get_nodes() + + else if name in coffeekup.tags or name in ['tag', 'coffeescript'] + if name is 'tag' + name = args.shift()[1] + + # Compile coffeescript strings to js + if name is 'coffeescript' + name = 'script' + for arg in args + # Dynamically generated coffeescript not supported + if arg[0] not in ['string', 'object', 'function'] + throw new Error 'Invalid argument to coffeescript function' + # Make sure this isn't an id class string, and compile it to js + if arg[0] is 'string' and (args.length is 1 or arg isnt args[0]) + arg[1] = coffee.compile arg[1], bare: yes + + code = new Code w.parent() + code.append "<#{name}" + + # Iterate over the arguments to the tag function and build the tag html + # as calls to the `text()` function. + for arg in args + switch arg[0] + + when 'function' + # If this is a `" + + 'CoffeeScript helper (function)': + template: "coffeescript -> alert 'hi'" + expected: "" + + 'CoffeeScript helper (string)': + template: "coffeescript \"alert 'hi'\"" + expected: "" + + 'Context vars': + template: "h1 @foo" + expected: '

bar

' + params: {foo: 'bar'} + + 'Local vars, hardcoded': + template: 'h1 "harcoded: " + obj.foo' + run: -> + obj = {foo: 'bar'} + @compiled = ck.compile(@template, hardcode: {obj}) + @expected = '

harcoded: bar

' + @result = @compiled() + @success = @result is @expected + if @success + obj.foo = 'baz' + @result = @compiled() + @success = @result is @expected + + 'Local vars, hard-coded (functions)': + template: "h1 \"The sum is: \#{sum 1, 2}\"" + expected: '

The sum is: 3

' + params: {hardcode: {sum: (a, b) -> a + b}} + + 'Local vars, hard-coded ("helpers")': + template: "textbox id: 'foo'" + expected: '' + params: + hardcode: + textbox: (attrs) -> + tag 'input', + id: attrs.id + name: attrs.id + type: 'text' + + 'Local vars': + template: 'h1 "dynamic: " + obj.foo' + run: -> + obj = {foo: 'bar'} + @expected = '

dynamic: bar

' + @result = render(@template, locals: {obj: obj}) + @success = @result is @expected + if @success + obj.foo = 'baz' + @expected = '

dynamic: baz

' + @result = render(@template, locals: {obj: obj}) + @success = @result is @expected + + 'Comments': + template: "comment 'Comment'" + expected: '' + + 'Escaping': + template: "h1 h(\"\")" + expected: "

<script>alert('"pwned" by c&a &copy;')</script>

" + + 'Autoescaping': + template: "h1 \"\"" + expected: "

<script>alert('"pwned" by c&a &copy;')</script>

" + params: + autoescape: yes + + 'ID/class shortcut (combo)': + template: "div '#myid.myclass1.myclass2', 'foo'" + expected: '
foo
' + + 'ID/class shortcut (ID only)': + template: "div '#myid', 'foo'" + expected: '
foo
' + + 'ID/class shortcut (one class only)': + template: "div '.myclass', 'foo'" + expected: '
foo
' + + 'ID/class shortcut (multiple classes)': + template: "div '.myclass.myclass2.myclass3', 'foo'" + expected: '
foo
' + + 'ID/class shortcut (no string contents)': + template: "img '#myid.myclass', src: '/pic.png'" + expected: '' + + 'Attribute values': + template: "br vrai: yes, faux: no, undef: @foo, nil: null, str: 'str', num: 42, arr: [1, 2, 3].join(','), obj: {foo: 'bar'}, func: ->" + expected: '
' + + 'IE conditionals': + template: """ + html -> + head -> + title 'test' + ie 'gte IE8', -> + link href: 'ie.css', rel: 'stylesheet' + """ + expected: 'test' + #expected: ''' + # + # + # test + # + # + # + # + #''' + #params: {format: yes} + + 'yield': + template: "p \"This text could use \#{yield -> strong -> a href: '/', 'a link'}.\"" + expected: '

This text could use a link.

' + +ck = require './src/coffeekup' +render = ck.render + +@run = -> + {print} = require 'sys' + colors = {red: "\033[31m", redder: "\033[91m", green: "\033[32m", normal: "\033[0m"} + printc = (color, str) -> print colors[color] + str + colors.normal + + [total, passed, failed, errors] = [0, [], [], []] + + for name, test of tests + total++ + try + if not test.params? + test.params = + optimize: true + cache: on + else + test.params.optimize = true + test.params.cache = true + test.original_params = JSON.stringify test.params + + if test.run + test.run() + else + test.result = ck.render(test.template, test.params) + test.success = test.result is test.expected + + if test.success + passed.push name + print "[Passed] #{name}\n" + else + failed.push name + printc 'red', "[Failed] #{name}\n" + catch ex + test.result = ex + errors.push name + printc 'redder', "[Error] #{name}\n" + + print "\n#{total} tests, #{passed.length} passed, #{failed.length} failed, #{errors.length} errors\n\n" + + if failed.length > 0 + printc 'red', "FAILED:\n\n" + + for name in failed + t = tests[name] + print "- #{name}:\n" + print t.template + "\n" + print t.original_params + "\n" if t.params + printc 'green', t.expected + "\n" + printc 'red', t.result + "\n\n" + + if errors.length > 0 + printc 'redder', "ERRORS:\n\n" + + for name in errors + t = tests[name] + print "- #{name}:\n" + print t.template + "\n" + printc 'green', t.expected + "\n" + printc 'redder', t.result.stack + "\n\n" + +@run()