diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1d7f5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.gem +.DS_Store diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..cba44f6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +# Specify your gem's dependencies in cookie_store.gemspec +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..d68b861 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,33 @@ +PATH + remote: . + specs: + ruby-ejs (1.1.0) + +GEM + remote: https://rubygems.org/ + specs: + ansi (1.5.0) + builder (3.2.2) + execjs (2.7.0) + minitest (5.11.3) + minitest-reporters (1.2.0) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + rake (12.3.1) + ruby-progressbar (1.9.0) + +PLATFORMS + ruby + +DEPENDENCIES + bundler + execjs + minitest + minitest-reporters + rake + ruby-ejs! + +BUNDLED WITH + 1.16.0 diff --git a/README.md b/README.md index b5db320..437f31e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -EJS (Embedded JavaScript) template compiler for Ruby +EJS (Embedded JavaScript) template compiler for Ruby [](https://circleci.com/gh/malomalo/ruby-ejs) ==================================================== EJS templates embed JavaScript code inside `<% ... %>` tags, much like @@ -25,6 +25,25 @@ The EJS tag syntax is as follows: its string value into the template output. * `<%- ... %>` behaves like `<%= ... %>` but HTML-escapes its output. +If a evalation tag (`<%=`) ends with an open function, the function +return a compiled template. For example: + +```erb +<% formTag = function(template) { return '
'; } %> + +<%= formTag(function () { %> + +<% }) %> +``` + +generates: + +```html + +``` + If you have the [ExecJS](https://github.com/sstephenson/execjs/) library and a suitable JavaScript runtime installed, you can pass a template and an optional hash of local variables to `EJS.evaluate`: diff --git a/Rakefile b/Rakefile index 2b9add8..67b47de 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,10 @@ +require 'bundler/setup' +require "bundler/gem_tasks" require "rake/testtask" task :default => :test Rake::TestTask.new do |t| - t.libs << "test" - t.warning = true + t.libs << 'test' + t.test_files = FileList['test/**/*_test.rb'] end diff --git a/ejs.gemspec b/ejs.gemspec deleted file mode 100644 index 7db0406..0000000 --- a/ejs.gemspec +++ /dev/null @@ -1,14 +0,0 @@ -Gem::Specification.new do |s| - s.name = "ejs" - s.version = "1.1.1" - s.summary = "EJS (Embedded JavaScript) template compiler" - s.description = "Compile and evaluate EJS (Embedded JavaScript) templates from Ruby." - - s.files = Dir["README.md", "LICENSE", "lib/**/*.rb"] - - s.add_development_dependency "execjs", "~> 0.4" - - s.authors = ["Sam Stephenson"] - s.email = ["sstephenson@gmail.com"] - s.homepage = "https://github.com/sstephenson/ruby-ejs/" -end diff --git a/lib/ejs.rb b/lib/ejs.rb index 686649a..140ae90 100644 --- a/lib/ejs.rb +++ b/lib/ejs.rb @@ -1,26 +1,28 @@ -# EJS (Embedded JavaScript) template compiler for Ruby -# This is a port of Underscore.js' `_.template` function: -# http://documentcloud.github.com/underscore/ - module EJS - JS_UNESCAPES = { - '\\' => '\\', - "'" => "'", - 'r' => "\r", - 'n' => "\n", - 't' => "\t", - 'u2028' => "\u2028", - 'u2029' => "\u2029" - } - JS_ESCAPES = JS_UNESCAPES.invert - JS_UNESCAPE_PATTERN = /\\(#{Regexp.union(JS_UNESCAPES.keys)})/ - JS_ESCAPE_PATTERN = Regexp.union(JS_ESCAPES.keys) + DEFAULTS = { + open_tag: '<%', + close_tag: '%>', + + open_tag_modifiers: { + escape: '=', + unescape: '-', + comment: '#', + literal: '%' + }, + + close_tag_modifiers: { + trim: '-', + literal: '%' + }, + + escape: nil + } + + ASSET_DIR = File.join(__dir__, 'ruby', 'ejs', 'assets') + class << self - attr_accessor :evaluation_pattern - attr_accessor :interpolation_pattern - attr_accessor :escape_pattern - + # Compiles an EJS template to a JavaScript function. The compiled # function takes an optional argument, an object specifying local # variables in the template. You can optionally pass the @@ -31,71 +33,255 @@ class << self # EJS.compile("Hello <%= name %>") # # => "function(obj){...}" # + def transform(source, options = {}) + options = default(options) + + output = if options[:escape] + "import {" + options[:escape].split('.').reverse.join(" as escape} from '") + "';\n" + else + "import {escape} from 'ejs';\n" + end + + fs = function_source(source, options) + output << fs[1] + output << "export default function (locals) {\n" + output << fs[0] + output << "}" + + output + end + def compile(source, options = {}) - source = source.dup - - js_escape!(source) - replace_escape_tags!(source, options) - replace_interpolation_tags!(source, options) - replace_evaluation_tags!(source, options) - "function(obj){var __p=[],print=function(){__p.push.apply(__p,arguments);};" + - "with(obj||{}){__p.push('#{source}');}return __p.join('');}" + options = default(options) + + output = "function(locals, escape) {\n" + output << function_source(source, options)[0] + output << "}" + output end + # Evaluates an EJS template with the given local variables and # compiler options. You will need the ExecJS # (https://github.com/sstephenson/execjs/) library and a # JavaScript runtime available. # - # EJS.evaluate("Hello <%= name %>", :name => "world") + # EJS.evaluate("Hello <%= name %>", name: "world") # # => "Hello world" # def evaluate(template, locals = {}, options = {}) require "execjs" - context = ExecJS.compile("var evaluate = #{compile(template, options)}") + context = ExecJS.compile(<<-JS) + #{escape_function} + + var template = #{compile(template, options)} + var evaluate = function(locals) { + return template(locals, escape); + } + JS context.call("evaluate", locals) end protected - def js_escape!(source) - source.gsub!(JS_ESCAPE_PATTERN) { |match| '\\' + JS_ESCAPES[match] } - source + + def default(options) + options = DEFAULTS.merge(options) + + [:open_tag_modifiers, :close_tag_modifiers].each do |k| + DEFAULTS[k].each do |sk, v| + next if options[k].has_key?(sk) + options[k] = v + end + end + + options end - def js_unescape!(source) - source.gsub!(JS_UNESCAPE_PATTERN) { |match| JS_UNESCAPES[match[1..-1]] } - source + def escape_module + escape_function.sub('function', 'export function') end - def replace_escape_tags!(source, options) - source.gsub!(options[:escape_pattern] || escape_pattern) do - "',(''+#{js_unescape!($1)})#{escape_function},'" - end + def escape_function(name='escape') + <<-JS + function #{name}(string) { + if (string !== undefined && string != null) { + return String(string).replace(/[&<>'"\\/]/g, function (c) { + return '' + c.codePointAt(0) + ';'; + }); + } else { + return ''; + } + } + JS end + + def chars_balanced?(str, chars) + a = chars[0] + b = chars[1] + str = str.sub(/"(\\.|[^"])+"/, '') + str = str.sub(/'(\\.|[^'])+'/, '') + a_count = str.scan(/#{a}/).length + b_count = str.scan(/#{b}/).length + + a_count - b_count + end + + + def digest(source, options) + open_tag_count = 0 + close_tag_count = 0 + tag_length = nil + # var index, tagType, tagModifier, tagModifiers, matchingModifier, prefix; + index = nil + tag_type = nil + tag_modifiers = nil + tag_modifier = nil + prefix =nil + matching_modifier = nil + last_tag_modifier = nil + next_open_index = source.index(options[:open_tag]) + next_close_index = source.index(options[:close_tag]) + + while next_open_index || next_close_index + if (next_close_index && (!next_open_index || next_close_index < next_open_index)) + index = next_close_index + tag_type = :close + tag_length = options[:close_tag].length + tag_modifiers = options[:close_tag_modifiers] + close_tag_count += 1 + matching_modifier = tag_modifiers.find do |k, v| + source[index - v.length, v.length] == v + end + else + index = next_open_index + tag_type = :open + tag_length = options[:open_tag].length + tag_modifiers = options[:open_tag_modifiers] + open_tag_count += 1 + matching_modifier = tag_modifiers.find do |k, v| + source[index + tag_length, v.length] == v + end + end + + if matching_modifier + tag_length += matching_modifier[1].length + tag_modifier = matching_modifier[0] + else + tag_modifier = :default + end + + if tag_modifier == :literal + if tag_type == :open + source = source[0, tag_length - matching_modifier[1].length] + source[(index + tag_length)..-1] + # source = source.slice(0, index + tagLength - matchingModifier[1].length) + source.slice(index + tagLength); + open_tag_count -= 1 + else + close_tag_count -= 1 + if index == 0 + source = source[(index + matching_modifier[1].length)..-1] + else + source = source[0..index] + source[(index + matching_modifier[1].length)..-1] + end + end + + next_open_index = source.index(options.openTag, index + tag_length - matching_modifier[1].length); + next_close_index = source.index(options.closeTag, index + tag_length - matching_modifier[1].length); + next + end + + if index != 0 + if tag_type == :close + if matching_modifier + yield(source[0...(matching_modifier[1].length)], :js, last_tag_modifier) + else + yield(source[0...index], :js, last_tag_modifier) + end + else + yield(source[0...index], :text, last_tag_modifier) + end + + source = source[index..-1] + end - def replace_evaluation_tags!(source, options) - source.gsub!(options[:evaluation_pattern] || evaluation_pattern) do - "'); #{js_unescape!($1)}; __p.push('" + if tag_type == :close && matching_modifier + source = source[(tag_length - matching_modifier[1].length)..-1] + source.lstrip! + else + source = source[tag_length..-1] + end + next_open_index = source.index(options[:open_tag]) + next_close_index = source.index(options[:close_tag]) + last_tag_modifier = tag_modifier end + + if open_tag_count != close_tag_count + raise "Could not find closing tag for \"#{options[(tag_type.to_s + '_tag').to_sym]}\"." + end + + yield(source, :text, tag_modifier) end - def replace_interpolation_tags!(source, options) - source.gsub!(options[:interpolation_pattern] || interpolation_pattern) do - "', #{js_unescape!($1)},'" + + def function_source(source, options) + stack = [] + imports = "" + output = " var __output = [], __append = __output.push.bind(__output);\n" + output << " with (locals || {}) {\n" unless options[:strict] + + digest(source, options) do |segment, type, modifier| + if type == :js + if segment.match(/\A\s*\}.*\{\s*\Z/m) + output << " " << segment << "\n" + elsif segment.match(/\A\s*\}/m) + case stack.pop + when :escape + output << "\n return __output.join(\"\");\n" + output << segment << " ));\n" + when :unescape + output << "\n return __output.join(\"\");\n" + output << segment << " );\n" + else + output << " " << segment << "\n" + end + elsif segment.match(/\)\s*\{\s*\Z/m) + stack << modifier + case modifier + when :escape + output << " __append(escape(" << segment + output << "\n var __output = [], __append = __output.push.bind(__output);\n" + when :unescape + output << " __append(" << segment + output << "\n var __output = [], __append = __output.push.bind(__output);\n" + else + output << " " << segment << "\n" + end + else + case modifier + when :escape + output << " __append(escape(" << segment << "));\n" + when :unescape + output << " __append(" << segment << ");\n" + else + if segment =~ /\A\s*import/ + imports << segment.strip + imports << ';' unless segment =~ /;\s*\Z/ + imports << "\n" + else + output << " " << segment << "\n" + end + end + end + elsif segment.length > 0 + output << ' __append("' + segment.gsub("\\"){"\\\\"}.gsub(/\n/, '\\n').gsub(/\r/, '\\r').gsub('"', '\\"') + "\");\n" + end end - end - def escape_function - ".replace(/&/g, '&')" + - ".replace(//g, '>')" + - ".replace(/\"/g, '"')" + - ".replace(/'/g, ''')" + - ".replace(/\\//g,'/')" + output << " }\n" unless options[:strict] + output << " return __output.join(\"\");\n" + imports << "\n" + + [output, imports] end + end - - self.evaluation_pattern = /<%([\s\S]+?)%>/ - self.interpolation_pattern = /<%=([\s\S]+?)%>/ - self.escape_pattern = /<%-([\s\S]+?)%>/ end diff --git a/lib/ruby/ejs.rb b/lib/ruby/ejs.rb new file mode 100644 index 0000000..4ddb7e9 --- /dev/null +++ b/lib/ruby/ejs.rb @@ -0,0 +1 @@ +require File.expand_path(File.join(__dir__, '..', 'ejs')) \ No newline at end of file diff --git a/lib/ruby/ejs/assets/ejs.js b/lib/ruby/ejs/assets/ejs.js new file mode 100644 index 0000000..f7714b8 --- /dev/null +++ b/lib/ruby/ejs/assets/ejs.js @@ -0,0 +1,9 @@ +export function escape(string) { + if (string !== undefined && string != null) { + return String(string).replace(/[&<>'"\\/]/g, function (c) { + return '' + c.codePointAt(0) + ';'; + }); + } else { + return ''; + } +} \ No newline at end of file diff --git a/ruby-ejs.gemspec b/ruby-ejs.gemspec new file mode 100644 index 0000000..a08b3e9 --- /dev/null +++ b/ruby-ejs.gemspec @@ -0,0 +1,19 @@ +Gem::Specification.new do |s| + s.name = "ruby-ejs" + s.version = "1.2.0" + s.licenses = ['MIT'] + s.summary = "EJS (Embedded JavaScript) template compiler" + s.description = "Compile EJS (Embedded JavaScript) templates in Ruby." + + s.files = Dir["README.md", "LICENSE", "lib/**/*.{rb,js}"] + + s.add_development_dependency "rake" + s.add_development_dependency "bundler" + s.add_development_dependency "minitest" + s.add_development_dependency "minitest-reporters" + s.add_development_dependency "execjs" + + s.authors = ["Jonathan Bracy"] + s.email = ["jonbracy@gmail.com"] + s.homepage = "https://github.com/malomalo/ruby-ejs" +end \ No newline at end of file diff --git a/test/compilation_test.rb b/test/compilation_test.rb new file mode 100644 index 0000000..96ec7fb --- /dev/null +++ b/test/compilation_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' + +class CompilationTest < Minitest::Test + + test "compile" do + result = EJS.compile("Hello <%= name %>") + + assert_match FUNCTION_PATTERN, result + assert_no_match(/Hello \<%= name %\>/, result) + assert_equal(<<~JS.strip, result) + function(locals, escape) { + var __output = [], __append = __output.push.bind(__output); + with (locals || {}) { + __append("Hello "); + __append(escape( name )); + } + return __output.join(""); + } + JS + end + +end diff --git a/test/custom_pattern_test.rb b/test/custom_pattern_test.rb new file mode 100644 index 0000000..40564bc --- /dev/null +++ b/test/custom_pattern_test.rb @@ -0,0 +1,46 @@ +require 'test_helper' + +class CustomPatternTest < Minitest::Test + + test "compile with custom defults" do + old_defaults = EJS::DEFAULTS + EJS::DEFAULTS = { + open_tag: '{{', + close_tag: '}}', + + open_tag_modifiers: { + escape: '=', + unescape: '-', + comment: '#', + literal: '%' + }, + + close_tag_modifiers: { + trim: '-', + literal: '%' + }, + + escape: nil + } + + result = EJS.compile("Hello {{= name }}") + assert_equal(<<~JS.strip, result) + function(locals, escape) { + var __output = [], __append = __output.push.bind(__output); + with (locals || {}) { + __append("Hello "); + __append(escape( name )); + } + return __output.join(""); + } + JS + ensure + EJS::DEFAULTS = old_defaults + end + + test "compile with custom syntax" do + standard_result = EJS.compile("Hello <%= name %>") + question_result = EJS.compile("Hello = name ?>", open_tag: '', close_tag: '?>') + assert_equal standard_result, question_result + end +end \ No newline at end of file diff --git a/test/evaluation_test.rb b/test/evaluation_test.rb new file mode 100644 index 0000000..5defd12 --- /dev/null +++ b/test/evaluation_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' + +class EvaluationTest < Minitest::Test + + test "quotes" do + template = "<%= thing %> is gettin' on my noives!" + assert_equal "This is gettin' on my noives!", EJS.evaluate(template, thing: "This") + end + + test "backslashes" do + template = "<%= thing %> is \\ridanculous" + assert_equal "This is \\ridanculous", EJS.evaluate(template, thing: "This") + end + + test "backslashes into interpolation" do + template = %q{<%- "Hello \"World\"" %>} + assert_equal 'Hello "World"', EJS.evaluate(template) + end + + test "implicit semicolon" do + template = "<% var foo = 'bar' %>" + assert_equal '', EJS.evaluate(template) + end + + test "iteration" do + template = "Just some text. Hey, I know this is silly but it aids consistency.
Just some text. Hey, I know this is silly but it aids consistency.