From 640e0ed063e18c384b1880c3c0e57c2e1e36b218 Mon Sep 17 00:00:00 2001 From: Jeremy Jackson Date: Fri, 21 Nov 2025 10:32:02 -0700 Subject: [PATCH] Adds better compatability with the javascript implementation of handlebars, and documents shortcomings. --- lib/ruby-handlebars.rb | 49 +- lib/ruby-handlebars/context.rb | 103 +-- lib/ruby-handlebars/escapers/html_escaper.rb | 6 +- lib/ruby-handlebars/helper.rb | 20 +- lib/ruby-handlebars/helpers/default_helper.rb | 4 +- lib/ruby-handlebars/helpers/each_helper.rb | 2 +- .../helpers/helper_missing_helper.rb | 3 +- .../helpers/inline_partial_helper.rb | 16 + .../helpers/register_default_helpers.rb | 2 + lib/ruby-handlebars/parser.rb | 282 +++++-- lib/ruby-handlebars/safe_string.rb | 7 + lib/ruby-handlebars/template.rb | 2 - lib/ruby-handlebars/tree.rb | 177 ++-- spec/compatibility_spec.rb | 794 ++++++++++++++++++ spec/parser_spec.rb | 2 +- .../helpers/each_helper_spec.rb | 9 + .../helpers/helper_missing_helper_spec.rb | 2 +- .../helpers/register_default_helpers_spec.rb | 27 +- spec/ruby-handlebars/helpers/shared.rb | 7 +- 19 files changed, 1235 insertions(+), 279 deletions(-) create mode 100644 lib/ruby-handlebars/helpers/inline_partial_helper.rb create mode 100644 lib/ruby-handlebars/safe_string.rb create mode 100644 spec/compatibility_spec.rb diff --git a/lib/ruby-handlebars.rb b/lib/ruby-handlebars.rb index a554942..68365dc 100644 --- a/lib/ruby-handlebars.rb +++ b/lib/ruby-handlebars.rb @@ -1,12 +1,21 @@ -require_relative 'ruby-handlebars/parser' -require_relative 'ruby-handlebars/tree' -require_relative 'ruby-handlebars/template' -require_relative 'ruby-handlebars/helper' -require_relative 'ruby-handlebars/helpers/register_default_helpers' -require_relative 'ruby-handlebars/escapers/html_escaper' +require "parslet" + +require_relative "ruby-handlebars/context" +require_relative "ruby-handlebars/helper" +require_relative "ruby-handlebars/parser" +require_relative "ruby-handlebars/safe_string" +require_relative "ruby-handlebars/template" +require_relative "ruby-handlebars/tree" +require_relative "ruby-handlebars/escapers/html_escaper" +require_relative "ruby-handlebars/helpers/register_default_helpers" module Handlebars MissingPartial = Class.new(StandardError) + + def self.escape_expression(expression) + Escapers::HTMLEscaper.escape(expression) + end + class Handlebars attr_reader :escaper @@ -22,30 +31,36 @@ def compile(template) Template.new(self, template_to_ast(template)) end - def register_helper(name, &fn) - @helpers[name.to_s] = Helper.new(self, fn) + def register_helper(name, as: false, &fn) + (as ? @as_helpers : @helpers)[name.to_s] = Helper.new(self, fn) end def register_as_helper(name, &fn) @as_helpers[name.to_s] = Helper.new(self, fn) end - def get_helper(name) - @helpers[name.to_s] - end - - def get_as_helper(name) - @as_helpers[name.to_s] + def get_helper(name, as: false) + (as ? @as_helpers : @helpers)[name.to_s] end def register_partial(name, content) @partials[name.to_s] = { content: content, compiled: nil } end - def get_partial(name) - raise(::Handlebars::MissingPartial, "Partial \"#{name}\" not registered.") unless @partials[name.to_s] + def get_partial(name, raise_on_missing: true) + partial = @partials[name.to_s] + + if partial.nil? + raise(::Handlebars::MissingPartial, "Partial \"#{name}\" not registered.") if raise_on_missing + return nil + end + + # compile the partial now that we know it's going to be used. + partial[:compiled] ||= Template.new(self, template_to_ast(partial[:content])) + end - @partials[name.to_s][:compiled] ||= Template.new(self, template_to_ast(@partials[name.to_s][:content])) + def escape_expression(expression) + @escaper.escape(expression) end def set_escaper(escaper = nil) diff --git a/lib/ruby-handlebars/context.rb b/lib/ruby-handlebars/context.rb index 7abaf05..9f1c241 100644 --- a/lib/ruby-handlebars/context.rb +++ b/lib/ruby-handlebars/context.rb @@ -2,56 +2,31 @@ module Handlebars class Context - PATH_REGEX = /\.\.\/|[^.\/]+/ - - class Data - extend Forwardable - - def_delegators :@hash, :[]=, :keys, :key?, :empty?, :merge!, :map - - def initialize(hash) - @hash = hash - end - - def [](k) - return {} unless @hash.respond_to?(:has_key?) - return @hash[k] if @hash.has_key?(k) - return @hash[k.to_s] if @hash.has_key?(k.to_s) - - return true if k == :true - return false if k == :false - return nil if k == :nil || k == :null - to_number(k.to_s) || nil - end + extend Forwardable - def dup - self.class.new(@hash.dup) # shallow copy. - end - - def has_key?(_k) - true # yeah, we'll respond to anything. - end - - def respond_to?(val, _ = false) - %w[[] has_key?].include?(val.to_s) ? true : false - end - - private + PATH_REGEX = /\.\.\/|[^.\/]+/ - def to_number(val) - result = Float(val) - (result % 1).zero? ? result.to_i : result - rescue ArgumentError, TypeError - false - end - end + def_delegators :@hbs, :escaper, :get_helper, :get_partial, :register_partial def initialize(hbs, data) @hbs = hbs - @data = Data.new(data) + @data = data || {} end def get(path) + path = path.to_s + return true if path == 'true' + return false if path == 'false' + return nil if %w[nil null undefined].include?(path) + + if (number = parse_number(path)) + number + else + resolve(path) + end + end + + def resolve(path) items = path.to_s.scan(PATH_REGEX) items[-1] = "#{items.shift}#{items[-1]}" if items.first == '@' @@ -68,28 +43,24 @@ def get(path) current end - def escaper - @hbs.escaper - end - - def get_helper(name) - @hbs.get_helper(name) - end - - def get_as_helper(name) - @hbs.get_as_helper(name) + def escape(string) + escaper.escape(string) end - def get_partial(name) - @hbs.get_partial(name) + def safe(string) + SafeString.new(string) end def add_item(key, value) locals[key.to_sym] = value end - def add_items(hash) - hash.map { |k, v| add_item(k, v) } + def add_items(enumerable) + if enumerable.is_a?(Array) + enumerable.each_with_index { |v, k| add_item(k.to_s, v) } + else + enumerable.map { |k, v| add_item(k, v) } + end end def with_nested_context @@ -124,7 +95,14 @@ def with_temporary_context(args = {}) private def locals - @locals ||= Data.new({}) + @locals ||= {} + end + + def parse_number(val) + result = Float(val) + (result % 1).zero? ? result.to_i : result + rescue ArgumentError, TypeError + false end def get_attribute(item, attribute) @@ -132,16 +110,11 @@ def get_attribute(item, attribute) str_attr = attribute.to_s if item.respond_to?(:[]) && item.respond_to?(:has_key?) - if item.has_key?(sym_attr) - return item[sym_attr] - elsif item.has_key?(str_attr) - return item[str_attr] - end + return item[sym_attr] if item.has_key?(sym_attr) + return item[str_attr] if item.has_key?(str_attr) end - if item.respond_to?(sym_attr) - return item.send(sym_attr) - end + item.send(sym_attr) if item.respond_to?(sym_attr) end end end diff --git a/lib/ruby-handlebars/escapers/html_escaper.rb b/lib/ruby-handlebars/escapers/html_escaper.rb index 25e4c7c..523fb7f 100644 --- a/lib/ruby-handlebars/escapers/html_escaper.rb +++ b/lib/ruby-handlebars/escapers/html_escaper.rb @@ -4,7 +4,11 @@ module Handlebars module Escapers class HTMLEscaper def self.escape(value) - CGI::escapeHTML(value) + if value.is_a?(SafeString) + value.to_s + else + CGI::escapeHTML(value.to_s) + end end end end diff --git a/lib/ruby-handlebars/helper.rb b/lib/ruby-handlebars/helper.rb index 4ff4c09..a460470 100644 --- a/lib/ruby-handlebars/helper.rb +++ b/lib/ruby-handlebars/helper.rb @@ -1,5 +1,3 @@ -require_relative 'tree' - module Handlebars class Helper def initialize(hbs, fn) @@ -7,11 +5,11 @@ def initialize(hbs, fn) @fn = fn end - def apply(context, arguments = [], block = [], else_block = [], collapse_options = {}) - apply_as(context, arguments, [], block, else_block, collapse_options) + def apply(name, context, arguments = [], block = [], else_block = [], collapse_options = {}) + apply_as(name, context, arguments, [], block, else_block, collapse_options) end - def apply_as(context, arguments = [], as_arguments = [], block = [], else_block = [], collapse_options = {}) + def apply_as(name, context, arguments = [], as_arguments = [], block = [], else_block = [], collapse_options = {}) arguments = [arguments] unless arguments.is_a? Array args = [context] hash = {} @@ -29,7 +27,17 @@ def apply_as(context, arguments = [], as_arguments = [], block = [], else_block blocks = split_block(block, else_block) - @fn.call(*args, hash: hash, block: blocks[0], else_block: blocks[1], collapse: collapse_options) + accepted_kwargs = @fn.parameters.select { |type, _| [:key, :keyreq].include?(type) }.map(&:last) + accepts_any_kwargs = @fn.parameters.any? { |type, _| type == :keyrest } + + kwargs = {} + kwargs[:name] = name if accepts_any_kwargs || accepted_kwargs.include?(:name) + kwargs[:hash] = hash.sort if accepts_any_kwargs || accepted_kwargs.include?(:hash) + kwargs[:block] = blocks[0] if accepts_any_kwargs || accepted_kwargs.include?(:block) + kwargs[:else_block] = blocks[1] if accepts_any_kwargs || accepted_kwargs.include?(:else_block) + kwargs[:collapse] = collapse_options if accepts_any_kwargs || accepted_kwargs.include?(:collapse) + + @fn.call(*args, **kwargs) end private diff --git a/lib/ruby-handlebars/helpers/default_helper.rb b/lib/ruby-handlebars/helpers/default_helper.rb index 31c202c..bf55866 100644 --- a/lib/ruby-handlebars/helpers/default_helper.rb +++ b/lib/ruby-handlebars/helpers/default_helper.rb @@ -2,11 +2,11 @@ module Handlebars module Helpers class DefaultHelper def self.register(hbs) - hbs.register_helper(self.registry_name) do |context, *parameters, **opts| + hbs.register_helper(self.registry_name, as: false) do |context, *parameters, **opts| self.apply(context, *parameters, **opts) end if self.respond_to?(:apply) - hbs.register_as_helper(self.registry_name) do |context, *parameters, as_names, **opts| + hbs.register_helper(self.registry_name, as: true) do |context, *parameters, as_names, **opts| self.apply_as(context, *parameters, as_names, **opts) end if self.respond_to?(:apply_as) end diff --git a/lib/ruby-handlebars/helpers/each_helper.rb b/lib/ruby-handlebars/helpers/each_helper.rb index 69cb5f9..43349b8 100644 --- a/lib/ruby-handlebars/helpers/each_helper.rb +++ b/lib/ruby-handlebars/helpers/each_helper.rb @@ -24,7 +24,7 @@ def self.apply_as(context, items, name, hash:, block:, else_block:, collapse:, * case items when Array items.each_with_index.map do |item, index| - add_and_execute(block, context, items, item, index, else_block, collapse, name => item) + add_and_execute(block, context, items, item, index, else_block, collapse, name => item, :@key => index.to_s) end.join('') when Hash items.each_with_index.map do |(key, value), index| diff --git a/lib/ruby-handlebars/helpers/helper_missing_helper.rb b/lib/ruby-handlebars/helpers/helper_missing_helper.rb index 82f6cf2..f26b4ae 100644 --- a/lib/ruby-handlebars/helpers/helper_missing_helper.rb +++ b/lib/ruby-handlebars/helpers/helper_missing_helper.rb @@ -10,7 +10,8 @@ def self.registry_name 'helperMissing' end - def self.apply(context, name, **_opts) + def self.apply(context, *args, name:, **_options) + # raise an exception raise(::Handlebars::UnknownHelper, "Helper \"#{name}\" does not exist" ) end end diff --git a/lib/ruby-handlebars/helpers/inline_partial_helper.rb b/lib/ruby-handlebars/helpers/inline_partial_helper.rb new file mode 100644 index 0000000..08c55ce --- /dev/null +++ b/lib/ruby-handlebars/helpers/inline_partial_helper.rb @@ -0,0 +1,16 @@ +require_relative 'default_helper' + +module Handlebars + module Helpers + class InlinePartialHelper < DefaultHelper + def self.registry_name + '*inline' + end + + def self.apply(context, name, block:, **_opts) + context.register_partial(name, block.fn(context)) + nil + end + end + end +end diff --git a/lib/ruby-handlebars/helpers/register_default_helpers.rb b/lib/ruby-handlebars/helpers/register_default_helpers.rb index 1abdfa1..7042282 100644 --- a/lib/ruby-handlebars/helpers/register_default_helpers.rb +++ b/lib/ruby-handlebars/helpers/register_default_helpers.rb @@ -1,6 +1,7 @@ require_relative 'each_helper' require_relative 'helper_missing_helper' require_relative 'if_helper' +require_relative 'inline_partial_helper' require_relative 'lookup_helper' require_relative 'unless_helper' require_relative 'with_helper' @@ -11,6 +12,7 @@ def self.register_default_helpers(hbs) EachHelper.register(hbs) HelperMissingHelper.register(hbs) IfHelper.register(hbs) + InlinePartialHelper.register(hbs) LookupHelper.register(hbs) UnlessHelper.register(hbs) WithHelper.register(hbs) diff --git a/lib/ruby-handlebars/parser.rb b/lib/ruby-handlebars/parser.rb index c54b8c8..656b2bf 100644 --- a/lib/ruby-handlebars/parser.rb +++ b/lib/ruby-handlebars/parser.rb @@ -1,126 +1,232 @@ -require 'parslet' - module Handlebars class Parser < Parslet::Parser - rule(:space) { match('\s').repeat(1) } - rule(:space?) { space.maybe } - rule(:dot) { str('.') } - rule(:gt) { str('>')} - rule(:hash) { str('#')} - rule(:slash) { str('/')} - rule(:ocurly) { str('{')} - rule(:ccurly) { str('}')} - rule(:pipe) { str('|')} - rule(:eq) { str('=')} - rule(:bang) { str('!') } - rule(:at) { str('@') } - rule(:tilde) { str('~') } - - rule(:docurly) { ocurly >> ocurly >> tilde.maybe.as(:collapse_before) } - rule(:dccurly) { tilde.maybe.as(:collapse_after) >> ccurly >> ccurly } - rule(:tocurly) { ocurly >> ocurly >> ocurly >> tilde.maybe.as(:collapse_before) } - rule(:tccurly) { tilde.maybe.as(:collapse_after) >> ccurly >> ccurly >> ccurly } - - rule(:else_kw) { str('else') } - rule(:as_kw) { str('as') } - - rule(:identifier) { (else_kw >> space? >> dccurly).absent? >> at.maybe >> str("../").repeat.maybe >> match['@\-a-zA-Z0-9_\.\?'].repeat(1) } - rule(:directory) { (else_kw >> space? >> dccurly).absent? >> match['@\-a-zA-Z0-9_\/\?'].repeat(1) } - rule(:path) { identifier >> (dot >> (identifier | else_kw)).repeat } - - rule(:nocurly) { match('[^{}]') } - rule(:eof) { any.absent? } - rule(:template_content) { + rule(:space) { match('\s').repeat(1) } + rule(:space?) { space.maybe } + rule(:dot) { str('.') } + rule(:gt) { str('>') } + rule(:hash) { str('#') } + rule(:slash) { str('/') } + rule(:backslash) { str('\\') } + rule(:ocurly) { str('{') } + rule(:ccurly) { str('}') } + rule(:pipe) { str('|') } + rule(:eq) { str('=') } + rule(:bang) { str('!') } + rule(:at) { str('@') } + rule(:tilde) { str('~') } + rule(:caret) { str('^') } + rule(:else_kw) { str('else') | caret } + rule(:as_kw) { str('as') } + + rule(:docurly) { ocurly >> ocurly >> tilde.maybe.as(:collapse_before) } + rule(:dccurly) { space? >> tilde.maybe.as(:collapse_after) >> ccurly >> ccurly } + rule(:tocurly) { ocurly >> ocurly >> ocurly >> tilde.maybe.as(:collapse_before) } + rule(:tccurly) { space? >> tilde.maybe.as(:collapse_after) >> ccurly >> ccurly >> ccurly } + rule(:qocurly) { ocurly >> ocurly >> ocurly >> ocurly } + rule(:qccurly) { ccurly >> ccurly >> ccurly >> ccurly } + + rule(:else_absent?) { (else_kw >> space? >> dccurly).absent? } + rule(:as_absent?) { (as_kw >> space? >> pipe).absent? } + + rule(:identifier) do + else_absent? >> + at.maybe >> + str("../").repeat.maybe >> + match['@\-a-zA-Z0-9_\.\?'].repeat(1) + end + rule(:directory) { else_absent? >> match['@\-a-zA-Z0-9_\/\?'].repeat(1) } + + # TODO: why is the else_kw here? + rule(:path) { identifier >> (dot >> (identifier | else_kw)).repeat } + + rule(:nocurly) { match('[^{}]') } + rule(:noocurly) { match('[^{]') } + rule(:noccurly) { match('[^}]') } + rule(:eof) { any.absent? } + + rule(:sq_string) { str("'") >> match("[^']").repeat.maybe.as(:str_content) >> str("'") } + rule(:dq_string) { str('"') >> match('[^"]').repeat.maybe.as(:str_content) >> str('"') } + rule(:string) { sq_string | dq_string } + + rule(:unsafe_helper) { space? >> identifier.as(:unsafe_helper_name) >> (space? >> parameters.as(:parameters)).maybe >> space? } + rule(:safe_helper) { space? >> identifier.as(:safe_helper_name) >> (space? >> parameters.as(:parameters)).maybe >> space? } + + rule(:parameter) { as_absent? >> (argument.as(:named_parameter) | (path | string).as(:parameter_name) | (str('(') >> safe_helper >> str(')'))) } + rule(:parameters) { parameter >> (space >> parameter).repeat } + rule(:as_parameters) { space >> as_kw >> space >> pipe >> space? >> parameters.as(:as_parameters) >> space? >> pipe } + + rule(:argument) { identifier.as(:key) >> space? >> eq >> space? >> parameter.as(:value) } + rule(:arguments) { argument >> (space >> argument).repeat } + + rule(:template_content) do ( - nocurly.repeat(1) | # A sequence of non-curlies - ocurly >> nocurly | # Opening curly that doesn't start a {{}} - ccurly | # Closing curly that is not inside a {{}} - ocurly >> eof # Opening curly that doesn't start a {{}} because it's the end - ).repeat(1).as(:template_content) } - - rule(:unsafe_replacement) { docurly >> space? >> path.as(:replaced_unsafe_item) >> space? >> dccurly } - rule(:safe_replacement) { tocurly >> space? >> path.as(:replaced_safe_item) >> space? >> tccurly } - - rule(:sq_string) { match("'") >> match("[^']").repeat.maybe.as(:str_content) >> match("'") } - rule(:dq_string) { match('"') >> match('[^"]').repeat.maybe.as(:str_content) >> match('"') } - rule(:string) { sq_string | dq_string } - - rule(:parameter) { - (as_kw >> space? >> pipe).absent? >> + (backslash >> ocurly).absent? >> + ( + nocurly.repeat(1) | # A sequence of non-curlies + ocurly >> nocurly | # Opening curly that doesn't start a {{}} + ccurly | # Closing curly that is not inside a {{}} + ocurly >> eof # Opening curly that doesn't start a {{}} because it's the end + ) + ).repeat(1).as(:template_content) + end + + rule(:raw_template_content) do ( - argument.as(:named_parameter) | - (path | string).as(:parameter_name) | - (str('(') >> space? >> identifier.as(:safe_helper_name) >> (space? >> parameters.as(:parameters)).maybe >> space? >> str(')')) - ) - } - rule(:parameters) { parameter >> (space >> parameter).repeat } - - rule(:argument) { identifier.as(:key) >> space? >> eq >> space? >> parameter.as(:value) } - rule(:arguments) { argument >> (space >> argument).repeat } + nocurly.repeat(1) | # A sequence of non-curlies + ocurly >> noocurly | # Opening curly that doesn't start a {{}} + ocurly >> ocurly >> noocurly | # .. + ocurly >> ocurly >> ocurly >> noocurly | # .. + ccurly | # Closing curly that is not inside a {{}} + ocurly >> eof # Opening curly that doesn't start a {{}} because it's the end + ).repeat(1).as(:raw_template_content) + end + + rule(:escaped_replacement) do + backslash >> + ((ocurly >> ocurly) | (ocurly >> ocurly >> ocurly)).as(:open_curly) >> + space? >> + ( + nocurly.repeat(1) | # A sequence of non-curlies + ocurly >> noocurly | # Opening curly that doesn't start a {{}} + ccurly >> noccurly # Closing curly that is not inside a {{}} + ).repeat(1).as(:escaped_content) >> + ((ccurly >> ccurly) | (ccurly >> ccurly >> ccurly)).as(:close_curly) + end + + rule(:unsafe_replacement) do + docurly >> + space? >> + path.as(:replaced_unsafe_item) >> + dccurly + end - rule(:comment) { docurly >> bang >> match('[^}]').repeat.maybe.as(:comment) >> dccurly } + rule(:safe_replacement) do + tocurly >> + space? >> + path.as(:replaced_safe_item) >> + tccurly + end - rule(:unsafe_helper) { docurly >> space? >> identifier.as(:unsafe_helper_name) >> (space? >> parameters.as(:parameters)).maybe >> space? >> dccurly } - rule(:safe_helper) { tocurly >> space? >> identifier.as(:safe_helper_name) >> (space? >> parameters.as(:parameters)).maybe >> space? >> tccurly } + rule(:comment) do + docurly >> + bang >> + space? >> + nocurly.repeat.maybe.as(:comment) >> + dccurly + end - rule(:helper) { unsafe_helper | safe_helper } + rule(:partial) do + docurly >> + gt >> + space? >> + directory.as(:partial_name) >> + space? >> + arguments.as(:arguments).maybe >> + dccurly + end - rule(:as_block_helper) { + rule(:block_partial) { docurly >> hash >> + gt >> space? >> - identifier.capture(:helper_name).as(:helper_name) >> - space >> parameters.as(:parameters) >> - space >> as_kw >> space >> pipe >> space? >> parameters.as(:as_parameters) >> space? >> pipe >> + directory.capture(:close_name).as(:partial_name) >> + space? >> + arguments.as(:arguments).maybe >> space? >> dccurly >> scope { block } >> - scope { - (docurly >> space? >> else_kw >> space? >> dccurly).as(:else_options) >> scope { - block_item.repeat.as(:else_block_items) - } - }.maybe >> dynamic { |src, scope| - (docurly >> slash >> space? >> str(scope.captures[:helper_name]) >> space? >> dccurly).as(:close_options) + ( + docurly >> + slash >> + space? >> + str(scope.captures[:close_name]) >> + space? >> + dccurly + ).as(:close_options) } } - rule(:block_helper) { + rule(:helper) do + (docurly >> unsafe_helper >> dccurly) | + (tocurly >> safe_helper >> tccurly) + end + + rule(:block_helper) do docurly >> hash >> space? >> - identifier.capture(:helper_name).as(:helper_name) >> + (str('*inline') | identifier).capture(:close_name).as(:helper_name) >> (space >> parameters.as(:parameters)).maybe >> + as_parameters.maybe >> space? >> dccurly >> scope { block } >> scope { - (docurly >> space? >> else_kw >> space? >> dccurly).as(:else_options) >> scope { + ( + docurly >> + space? >> + else_kw >> + dccurly + ).as(:else_options) >> + scope { block_item.repeat.as(:else_block_items) } }.maybe >> - dynamic { |src, scope| - (docurly >> slash >> space? >> str(scope.captures[:helper_name]) >> space? >> dccurly).as(:close_options) - } - } - - rule(:partial) { - docurly >> - gt >> - space? >> - directory.as(:partial_name) >> + dynamic do |src, scope| + ( + docurly >> + slash >> + space? >> + str(scope.captures[:close_name].to_s.gsub(/^\*/, '')) >> + space? >> + dccurly + ).as(:close_options) + end + end + + rule(:raw_block) do + qocurly >> space? >> - arguments.as(:arguments).maybe >> + identifier.capture(:close_name).as(:raw_helper_name) >> + (space >> parameters.as(:parameters)).maybe >> + as_parameters.maybe >> space? >> - dccurly - } - - rule(:block_item) { (template_content | unsafe_replacement | safe_replacement | helper | partial | block_helper | as_block_helper | comment) } - rule(:block) { block_item.repeat.as(:block_items) } + qccurly >> + scope { + raw_template_content.repeat(1).as(:block_items) + } >> + dynamic do |src, scope| + qocurly >> + slash >> + space? >> + str(scope.captures[:close_name]) >> + space? >> + qccurly + end + end + + rule(:block_item) do + template_content | + comment | + escaped_replacement | + unsafe_replacement | + safe_replacement | + partial | + block_partial | + helper | + block_helper | + raw_block + end + + rule(:block) do + block_item.repeat.as(:block_items) + end root :block end diff --git a/lib/ruby-handlebars/safe_string.rb b/lib/ruby-handlebars/safe_string.rb new file mode 100644 index 0000000..3766db8 --- /dev/null +++ b/lib/ruby-handlebars/safe_string.rb @@ -0,0 +1,7 @@ +module Handlebars + class SafeString < String + def to_s + self.class.new(self) + end + end +end diff --git a/lib/ruby-handlebars/template.rb b/lib/ruby-handlebars/template.rb index 25020b7..e3db16a 100644 --- a/lib/ruby-handlebars/template.rb +++ b/lib/ruby-handlebars/template.rb @@ -1,5 +1,3 @@ -require_relative 'context' - module Handlebars class Template def initialize(hbs, ast) diff --git a/lib/ruby-handlebars/tree.rb b/lib/ruby-handlebars/tree.rb index b296254..6e409d2 100644 --- a/lib/ruby-handlebars/tree.rb +++ b/lib/ruby-handlebars/tree.rb @@ -6,9 +6,9 @@ def eval(context) end end - class TemplateContent < TreeItem.new(:content) + class TemplateContent < TreeItem.new(:content, :prefix, :suffix) def _eval(context) - return content + [prefix, content, suffix].join end end @@ -17,27 +17,28 @@ def _eval(context) if context.get_helper(item.to_s).nil? context.get(item.to_s) else - context.get_helper(item.to_s).apply(context) + context.get_helper(item.to_s).apply(item.to_s, context) end end end class EscapedReplacement < Replacement def _eval(context) - context.escaper.escape(super(context).to_s) + result = super(context) + context.escape(result) end end class String < TreeItem.new(:content) def _eval(context) - return content + content end end class Parameter < TreeItem.new(:name) def _eval(context) if name.is_a?(Parslet::Slice) - context.get(name.to_s) + context.get(name) else name._eval(context) end @@ -51,16 +52,17 @@ def _eval(context) class Helper < TreeItem.new(:name, :parameters, :as_parameters, :collapse_before, :collapse_after, :block, :else_block, :close_options, :else_options) def _eval(context) - helper = as_parameters ? context.get_as_helper(name.to_s) : context.get_helper(name.to_s) + helper_name = name.to_s + helper = context.get_helper(helper_name, as: as_parameters) if helper.nil? # check the context for a matching key. - if context.get(name.to_s) + if context.get(helper_name) # swap the helper to "with" helper = context.get_helper('with') self.parameters = Parameter.new(Parslet::Slice.new(0, name.to_s)) else # fall back to the missing helper. - return context.get_helper('helperMissing').apply(context, String.new(name.to_s)) + helper = context.get_helper('helperMissing') end end @@ -69,32 +71,41 @@ def _eval(context) else: else_options, close: close_options } + if as_parameters - helper.apply_as(context, parameters, as_parameters, block, else_block, collapse) + helper.apply_as(helper_name, context, parameters, as_parameters, block, else_block, collapse) else - helper.apply(context, parameters, block, else_block, collapse) + helper.apply(helper_name, context, parameters, block, else_block, collapse) end end end class EscapedHelper < Helper def _eval(context) - context.escaper.escape(super(context).to_s) + result = super(context) + context.escape(result) end end - class Partial < TreeItem.new(:partial_name, :collapse_before, :collapse_after) + class Partial < TreeItem.new(:partial_name, :arguments, :collapse_before, :collapse_after, :block, :close_options) def _eval(context) - context.get_partial(partial_name.to_s).call_with_context(context) - end - end + [arguments].flatten.compact.map(&:values).map do |vals| + context.add_item(vals.first.to_s, vals.last._eval(context)) + end - class PartialWithArgs < TreeItem.new(:partial_name, :arguments, :collapse_before, :collapse_after) - def _eval(context) - [arguments].flatten.map(&:values).map do |vals| - context.add_item vals.first.to_s, vals.last._eval(context) + tree_block = Tree::Block.new(block) if block + result = tree_block&.fn(context) + + context.with_nested_temporary_context('@partial-block': result) do + return context.get('@../partial-block') if partial_name == '@partial-block' + + partial = context.get_partial(partial_name, raise_on_missing: block.nil?) + if partial + partial.call_with_context(context) + elsif block + result + end end - context.get_partial(partial_name.to_s).call_with_context(context) end end @@ -104,8 +115,11 @@ def _eval(context) end class Block < TreeItem.new(:items) + UnknownBlock = Class.new(StandardError) + def _eval(context) items.each_with_index.map do |item, i| + raise UnknownBlock, "Missing transform for #{item.inspect}" if item.is_a?(Hash) value = item._eval(context).to_s if i > 0 @@ -136,10 +150,28 @@ def add_item(i) class Transform < Parslet::Transform COLLAPSABLE = {collapse_before: simple(:collapse_before), collapse_after: simple(:collapse_after)} + rule(str_content: simple(:content)) { Tree::String.new(content) } + rule(parameter_name: simple(:name)) { Tree::Parameter.new(name) } + rule(COLLAPSABLE) { Tree::CollapseOptions.new(collapse_before, collapse_after) } + rule(block_items: subtree(:block_items)) { Tree::Block.new(block_items) } + rule(else_block_items: subtree(:else_block_items)) { Tree::Block.new(block_items) } + + # General + rule( template_content: simple(:content) ) { Tree::TemplateContent.new(content) } + rule( + raw_template_content: simple(:content) + ) { Tree::TemplateContent.new(content) } + + rule( + open_curly: simple(:open_curly), + close_curly: simple(:close_curly), + escaped_content: simple(:escaped_content) + ) { Tree::TemplateContent.new(escaped_content, open_curly, close_curly) } + rule(COLLAPSABLE.merge( replaced_unsafe_item: simple(:item) )) { Tree::EscapedReplacement.new(item, collapse_before, collapse_after) } @@ -148,22 +180,39 @@ class Transform < Parslet::Transform replaced_safe_item: simple(:item) )) { Tree::Replacement.new(item, collapse_before, collapse_after) } - rule( - str_content: simple(:content) - ) { Tree::String.new(content) } - - rule( - parameter_name: simple(:name) - ) { Tree::Parameter.new(name) } - rule(COLLAPSABLE.merge( comment: simple(:content) )) { Tree::Comment.new(content, collapse_before, collapse_after) } + # Partials + + rule(COLLAPSABLE.merge( + partial_name: simple(:name) + )) { Tree::Partial.new(name, nil, collapse_before, collapse_after) } + + rule(COLLAPSABLE.merge( + partial_name: simple(:name), + arguments: subtree(:arguments) + )) { Tree::Partial.new(name, arguments, collapse_before, collapse_after) } + + rule(COLLAPSABLE.merge( + partial_name: simple(:name), + block_items: subtree(:block_items), + close_options: subtree(:close_options) + )) { Tree::Partial.new(name, nil, collapse_before, collapse_after, block_items, close_options) } + + rule(COLLAPSABLE.merge( + partial_name: simple(:name), + arguments: subtree(:arguments), + block_items: subtree(:block_items), + close_options: subtree(:close_options) + )) { Tree::Partial.new(name, arguments, collapse_before, collapse_after, block_items, close_options) } + + # Helpers + rule( - unsafe_helper_name: simple(:name), - parameters: subtree(:parameters) - ) { Tree::EscapedHelper.new(name, parameters) } + safe_helper_name: simple(:name) + ) { Tree::Helper.new(name) } rule( safe_helper_name: simple(:name), @@ -171,28 +220,39 @@ class Transform < Parslet::Transform ) { Tree::Helper.new(name, parameters) } rule( - COLLAPSABLE - ) { Tree::CollapseOptions.new(collapse_before, collapse_after) } + raw_helper_name: simple(:name), + block_items: subtree(:block_items) + ) { Tree::Helper.new(name, [], nil, nil, nil, block_items) } + + rule( + raw_helper_name: simple(:name), + parameters: subtree(:parameters), + block_items: subtree(:block_items) + ) { Tree::Helper.new(name, parameters, nil, nil, nil, block_items) } + + rule(COLLAPSABLE.merge( + unsafe_helper_name: simple(:name), + )) { Tree::EscapedHelper.new(name, [], collapse_before, collapse_after) } rule(COLLAPSABLE.merge( unsafe_helper_name: simple(:name), parameters: subtree(:parameters) )) { Tree::EscapedHelper.new(name, parameters, collapse_before, collapse_after) } + rule(COLLAPSABLE.merge( + safe_helper_name: simple(:name), + )) { Tree::Helper.new(name, [], nil, collapse_before, collapse_after) } + rule(COLLAPSABLE.merge( safe_helper_name: simple(:name), parameters: subtree(:parameters) - )) do - Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after) - end + )) { Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after) } rule(COLLAPSABLE.merge( helper_name: simple(:name), block_items: subtree(:block_items), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, [], nil, collapse_before, collapse_after, block_items, nil, close_options) - end + )) { Tree::Helper.new(name, [], nil, collapse_before, collapse_after, block_items, nil, close_options) } rule(COLLAPSABLE.merge( helper_name: simple(:name), @@ -200,18 +260,14 @@ class Transform < Parslet::Transform else_block_items: subtree(:else_block_items), else_options: subtree(:else_options), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, [], nil, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) - end + )) { Tree::Helper.new(name, [], nil, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) } rule(COLLAPSABLE.merge( helper_name: simple(:name), parameters: subtree(:parameters), block_items: subtree(:block_items), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after, block_items, nil, close_options) - end + )) { Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after, block_items, nil, close_options) } rule(COLLAPSABLE.merge( helper_name: simple(:name), @@ -220,9 +276,7 @@ class Transform < Parslet::Transform else_block_items: subtree(:else_block_items), else_options: subtree(:else_options), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) - end + )) { Tree::Helper.new(name, parameters, nil, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) } rule(COLLAPSABLE.merge( helper_name: simple(:name), @@ -230,9 +284,7 @@ class Transform < Parslet::Transform as_parameters: subtree(:as_parameters), block_items: subtree(:block_items), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, parameters, as_parameters, collapse_before, collapse_after, block_items, close_options) - end + )) { Tree::Helper.new(name, parameters, as_parameters, collapse_before, collapse_after, block_items, close_options) } rule(COLLAPSABLE.merge( helper_name: simple(:name), @@ -242,25 +294,6 @@ class Transform < Parslet::Transform else_block_items: subtree(:else_block_items), else_options: subtree(:else_options), close_options: subtree(:close_options) - )) do - Tree::Helper.new(name, parameters, as_parameters, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) - end - - rule(COLLAPSABLE.merge( - partial_name: simple(:partial_name), - arguments: subtree(:arguments) - )) { Tree::PartialWithArgs.new(partial_name, arguments, collapse_before, collapse_after) } - - rule(COLLAPSABLE.merge( - partial_name: simple(:partial_name) - )) { Tree::Partial.new(partial_name, collapse_before, collapse_after) } - - rule( - block_items: subtree(:block_items) - ) { Tree::Block.new(block_items) } - - rule( - else_block_items: subtree(:else_block_items) - ) { Tree::Block.new(block_items) } + )) { Tree::Helper.new(name, parameters, as_parameters, collapse_before, collapse_after, block_items, else_block_items, close_options, else_options) } end end diff --git a/spec/compatibility_spec.rb b/spec/compatibility_spec.rb new file mode 100644 index 0000000..e7c7ec7 --- /dev/null +++ b/spec/compatibility_spec.rb @@ -0,0 +1,794 @@ +require 'spec_helper' +require 'ruby-handlebars/escapers/dummy_escaper' + +describe Handlebars do + let(:renderer) { Handlebars::Handlebars.new } + + class WhitespacelessString < String + def ==(other) + self.strip.gsub(/[\n\s]+/, ' ') == other.strip.gsub(/[\n\s]+/, ' ') + end + end + + # TODO: fix up pending specs and get full compatability. + + # These are comprised of examples taken from https://handlebarsjs.com/guide/ + # We want to try to get fully green on these, and any additional examples + # that work in the playground that don't work in the ruby version. + # + # Whitespace is still a work in progress. + + EXAMPLES = { + "simple-expressions": { + template: <<~TEMPLATE.strip, +

{{firstname}} {{lastname}}

+ TEMPLATE + input: { + firstname: "Yehuda", + lastname: "Katz" + }, + output: <<~OUTPUT.strip, +

Yehuda Katz

+ OUTPUT + }, + + "path-expressions-dot": { + template: <<~TEMPLATE.strip, + {{person.firstname}} {{person.lastname}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz" + } + }, + output: <<~OUTPUT.strip, + Yehuda Katz + OUTPUT + }, + + "path-expressions-slash": { + pending: "This is deprecated, so we could skip it if it's complex.", + template: <<~TEMPLATE.strip, + {{person/firstname}} {{person/lastname}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz" + } + }, + output: <<~OUTPUT.strip, + Yehuda Katz + OUTPUT + }, + + "path-expressions-dot-dot": { + template: <<~TEMPLATE.strip, + {{#each people}} + {{../prefix}} {{firstname}} + {{/each}} + TEMPLATE + input: { + people: [ + {firstname: "Nils"}, + {firstname: "Yehuda"}, + ], + prefix: "Hello", + }, + output: WhitespacelessString.new(<<~OUTPUT), + Hello Nils + Hello Yehuda + OUTPUT + }, + + # TODO: + # + # Identifiers may be any unicode character except for the following: + # + # Whitespace ! " # % & ' ( ) * + , . / ; < = > @ [ \ ] ^ ` { | } ~ + # + # In addition, the words true, false, null and undefined are only allowed in the first part of a path expression. + # + # To reference a property that is not a valid identifier, you can use segment-literal notation, [. You may not include a closing ] in a path-literal, but all other characters are allowed. + # + # JavaScript-style strings, " and ', may also be used instead of [ pairs. + # + + "literal-segments" => { + pending: "This requires updates to the parser.", + template: <<~TEMPLATE.strip, + {{!-- wrong: {{array.0.item}} --}} + correct: array.[0].item: {{array.[0].item}} + + {{!-- wrong: {{array.[0].item-class}} --}} + correct: array.[0].[item-class]: {{array.[0].[item-class]}} + + {{!-- wrong: {{./true}}--}} + correct: ./[true]: {{./[true]}} + TEMPLATE + input: { + array: [ + { + item: "item1", + "item-class": "class1", + }, + ], + true: "yes", + }, + output: WhitespacelessString.new(<<~OUTPUT), + correct: array.[0].item: item1 + + correct: array.[0].[item-class]: class1 + + correct: ./[true]: yes + OUTPUT + }, + + "html-escaping" => { + pending: "the ` and = aren't escaped to ` =", + template: <<~TEMPLATE.strip, + raw: {{{specialChars}}} + html-escaped: {{specialChars}} + TEMPLATE + input: { + specialChars: "& < > \" ' ` =" + }, + output: <<~OUTPUT.strip, + raw: & < > " ' ` = + html-escaped: & < > " ' ` = + OUTPUT + }, + + "helper-simple" => { + template: <<~TEMPLATE.strip, + {{firstname}} {{loud lastname}} + TEMPLATE + input: { + firstname: "Yehuda", + lastname: "Katz", + }, + helpers: { + loud: Proc.new { |_, str| str.upcase } + }, + output: <<~OUTPUT.strip, + Yehuda KATZ + OUTPUT + }, + + "helper-safestring" => { + # pending: "The example replaces ' with #x27 (hex) but CGI.escape uses #39 (decimal).", + template: <<~TEMPLATE.strip, + {{bold text}} + TEMPLATE + input: { + text: "Isn't this great?" + }, + helpers: { + bold: Proc.new do |_, text| + result = "#{Handlebars.escape_expression(text)}" + Handlebars::SafeString.new(result) + end + }, + output: <<~OUTPUT.strip, + Isn't this great? + OUTPUT + }, + + "helper-multiple-parameters" => { + template: <<~TEMPLATE.strip, + {{link "See Website" url}} + TEMPLATE + input: { + url: "https://yehudakatz.com/" + }, + helpers: { + link: Proc.new do |context, text, url| + context.safe(%{#{context.escape(text)}}) + end + }, + output: <<~OUTPUT.strip, + See Website + OUTPUT + }, + + "helper-dynamic-parameters" => { + template: <<~TEMPLATE.strip, + {{link people.text people.url}} + TEMPLATE + input: { + people: { + firstname: "Yehuda", + lastname: "Katz", + url: "https://yehudakatz.com/", + text: "See Website", + }, + }, + helpers: { + link: Proc.new do |_, text, url| + url = Handlebars.escape_expression(url) + text = Handlebars.escape_expression(text) + Handlebars::SafeString.new("" + text +"") + end + }, + output: <<~OUTPUT.strip, + See Website + OUTPUT + }, + + "helper-literals" => { + template: <<~TEMPLATE.strip, + {{progress "Search" 10.5 false}} + {{progress "Upload" 90 true}} + {{progress "Finish" 100 false}} + TEMPLATE + helpers: { + progress: Proc.new do |_, name, percent, stalled, **_options| + "#{"********************".slice(0, percent / 5.0)} #{percent}% #{name}#{(stalled ? " stalled" : "")}" + end + }, + output: <<~OUTPUT.strip, + ** 10.5% Search + ****************** 90% Upload stalled + ******************** 100% Finish + OUTPUT + }, + + "helper-hash-arguments" => { + template: <<~TEMPLATE.strip, + {{link "See Website" href=person.url class="person"}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz", + url: "https://yehudakatz.com/", + }, + }, + helpers: { + link: Proc.new do |context, text, **options| + attributes = options[:hash].map do |key, value| + %{#{context.escape(key)}="#{context.escape(value)}"} + end + context.safe("#{context.escape(text)}") + end + }, + output: <<~OUTPUT.strip, + See Website + OUTPUT + }, + + "helper-data-name-conflict" => { + pending: "Need to train parser on ./ and this/name, as well as get that baked into context", + template: <<~TEMPLATE.strip, + helper: {{name}} + data: {{./name}} or {{this/name}} or {{this.name}} + TEMPLATE + input: { + name: "Yehuda" + }, + helpers: { + name: Proc.new { 'Nils' } + }, + output: <<~OUTPUT.strip, + helper: Nils + data: Yehuda or Yehuda or Yehuda + OUTPUT + }, + + "sub-expressions" => { + template: <<~TEMPLATE.strip, + {{outer-helper (inner-helper 'abc') 'def'}} + TEMPLATE + input: {}, + helpers: { + "outer-helper": Proc.new { |_, v1, v2, **_| "[#{[v1, v2].compact.join('][')}]" }, + "inner-helper": Proc.new { |_, v1, v2, **_| "[#{[v1, v2].compact.join('][')}]" }, + }, + output: <<~OUTPUT.strip, + [[abc]][def] + OUTPUT + }, + + "whitespace-control1" => { + pending: "The parser needs to learn that ^ is inverse/else.", + template: <<~TEMPLATE.strip, + {{#each nav ~}} + + {{~#if test}} + {{~title}} + {{~^~}} + Empty + {{~/if~}} + + {{~/each}} + TEMPLATE + input: { + nav: [ + { url: "foo", test: true, title: "bar" }, + { url: "bar" } + ] + }, + output: <<~OUTPUT.strip, + barEmpty + OUTPUT + }, + + "whitespace-control2" => { + pending: "The parser needs to learn that ^ is inverse/else.", + template: <<~TEMPLATE.strip, + {{#each nav}} + + {{#if test}} + {{title}} + {{^}} + Empty + {{/if}} + + {{~/each}} + TEMPLATE + input: { + nav: [ + { url: "foo", test: true, title: "bar" }, + { url: "bar" } + ] + }, + output: WhitespacelessString.new(<<~OUTPUT), + + bar + + + Empty + + OUTPUT + }, + + "escaping-handlebars-expressions" => { + # pending: "This is not understood by the parser yet.", + template: <<~TEMPLATE.strip, + \\{{escaped1}} + {{{{raw}}}} + {{escaped2}} + {{{{/raw}}}} + TEMPLATE + input: { + escaped1: 'asdasdasd', + }, + helpers: { + raw: Proc.new do |context, block:, **options| + block.fn(context) + end + }, + output: WhitespacelessString.new(<<~OUTPUT), + {{escaped1}} + {{escaped2}} + OUTPUT + }, + + "partials/basic" => { + template: <<~TEMPLATE.strip, + {{> myPartial }} + TEMPLATE + input: { prefix: "Hello" }, + partials: { + myPartial: "{{prefix}}" + }, + output: <<~OUTPUT.strip, + Hello + OUTPUT + }, + + "partials/dynamic" => { + pending: "The parser doesn't understand how to parse the () in a partial name.", + template: <<~TEMPLATE.strip, + {{> (whichPartial) }} + TEMPLATE + helpers: { + whichPartial: Proc.new { "dynamicPartial" } + }, + partials: { + dynamicPartial: "Dynamo!" + }, + output: <<~OUTPUT.strip, + Dynamo! + OUTPUT + }, + + "partials/variable" => { + pending: "Several things here -- parsing (), calling helpers, and lookup accessing .", + template: <<~TEMPLATE.strip, + {{> (lookup . 'myVariable') }} + TEMPLATE + input: { + myVariable: "lookupMyPartial" + }, + partials: { + lookupMyPartial: "Found!" + }, + output: <<~OUTPUT.strip, + Found! + OUTPUT + }, + + "partials/other-context" => { + pending: "Not entirely sure -- looks like a parsing issue with the param to the partial", + template: <<~TEMPLATE.strip, + {{> myPartial myOtherContext }} + TEMPLATE + input: { + myOtherContext: { + information: "Interesting!", + }, + }, + partials: { + myPartial: "{{information}}" + }, + output: <<~OUTPUT.strip, + Interesting! + OUTPUT + }, + + "partials/parameters" => { + template: <<~TEMPLATE.strip, + {{> myPartial parameter=favoriteNumber }} + TEMPLATE + input: { + favoriteNumber: 123 + }, + partials: { + myPartial: "The result is {{parameter}}" + }, + output: <<~OUTPUT.strip, + The result is 123 + OUTPUT + }, + + "partials/parent-context" => { + template: <<~TEMPLATE.strip, + {{#each people}} + {{> myPartial prefix=../prefix firstname=firstname lastname=lastname}}. + {{/each}} + TEMPLATE + input: { + people: [ + { + firstname: "Nils", + lastname: "Knappmeier", + }, + { + firstname: "Yehuda", + lastname: "Katz", + }, + ], + prefix: "Hello", + }, + partials: { + myPartial: "{{prefix}}, {{firstname}} {{lastname}}" + }, + output: WhitespacelessString.new(<<~OUTPUT), + Hello, Nils Knappmeier. + Hello, Yehuda Katz. + OUTPUT + }, + + "partials/failover" => { + template: <<~TEMPLATE.strip, + {{#> myPartial }} + Failover content + {{/myPartial}} + TEMPLATE + output: WhitespacelessString.new(<<~OUTPUT.strip), + Failover content + OUTPUT + }, + + "partials/partial-block" => { + template: <<~TEMPLATE.strip, + {{#> layout }} + My Content + {{/layout}} + TEMPLATE + partials: { + layout: "Site Content {{> @partial-block }}" + }, + output: WhitespacelessString.new(<<~OUTPUT), + Site Content My Content + OUTPUT + }, + + "partials/inline" => { + template: <<~TEMPLATE.strip, + {{#*inline "myPartial"}} + My Content + {{/inline}} + {{#each people}} + {{> myPartial}} + {{/each}} + TEMPLATE + input: { + people: [ + { firstname: "Nils" }, + { firstname: "Yehuda" }, + ], + }, + output: WhitespacelessString.new(<<~OUTPUT), + My Content + My Content + OUTPUT + }, + + "partials/inline-blocks" => { + template: <<~TEMPLATE.strip, + {{#> layout}} + {{#*inline "nav"}} + My Nav + {{/inline}} + {{#*inline "content"}} + My Content + {{/inline}} + {{/layout}} + TEMPLATE + partials: { + layout: <<~PARTIAL.strip, + +
+ {{> content}} +
+ PARTIAL + }, + output: WhitespacelessString.new(<<~OUTPUT), + +
+ My Content +
+ OUTPUT + }, + + "block-helpers/basic-block" => { + template: <<~TEMPLATE.strip, +
+

{{title}}

+
+ {{#noop}}{{body}}{{/noop}} +
+
+ TEMPLATE + input: { + title: "title content", + body: "body content" + }, + helpers: { + noop: Proc.new do |context, block:, **_options| + block.fn(context) + end + }, + output: <<~OUTPUT.strip, +
+

title content

+
+ body content +
+
+ OUTPUT + }, + + "block-helpers/context-access" => { + pending: "Need to implement logic to handle ./ notation.", + template: <<~TEMPLATE.strip, + {{./noop}} + TEMPLATE + input: { + noop: "this is noop content", + }, + helpers: { + noop: Proc.new { } + }, + output: <<~OUTPUT.strip, + this is noop content + OUTPUT + }, + + "block-helpers/basic-variation" => { + template: <<~TEMPLATE.strip, +
+

{{title}}

+
+ {{#bold}}{{body}}{{/bold}} +
+
+ TEMPLATE + input: { + title: "title content", + body: "body content" + }, + helpers: { + bold: Proc.new do |context, block:| + Handlebars::SafeString.new('
' + block.fn(context) + "
"); + end + }, + output: <<~OUTPUT.strip, +
+

title content

+
+
body content
+
+
+ OUTPUT + }, + + "helper-simple" => { + template: <<~TEMPLATE.strip, +
+

{{title}}

+ {{#with story}} +
{{{intro}}}
+
{{{body}}}
+ {{/with}} +
+ TEMPLATE + input: { + title: "First Post", + story: { + intro: "Before the jump", + body: "After the jump" + } + }, + helpers: {}, + output: WhitespacelessString.new(<<~OUTPUT), +
+

First Post

+
Before the jump
+
After the jump
+
+ OUTPUT + }, + + # TODO: Add more of the helper examples in here. + + "raw-blocks" => { + template: <<~TEMPLATE.strip, + {{{{raw-loud}}}} + {{bar}} + {{{{/raw-loud}}}} + TEMPLATE + helpers: { + "raw-loud": Proc.new do |context, block:, **options| + block.fn(context).to_s.upcase + end + }, + output: WhitespacelessString.new(<<~OUTPUT), + {{BAR}} + OUTPUT + }, + + "hook-helper-missing" => { + pending: "We need to improve the compatibility of helper method missing behavior.", + template: <<~TEMPLATE.strip, + {{foo}} + {{foo true}} + {{foo 2 true}} + {{#foo true}}{{/foo}} + {{#foo}}rendered{{/foo}} + TEMPLATE + helpers: { + helperMissing: Proc.new do |_, *args, name:, **options| + Handlebars::SafeString.new("Missing: #{name}(#{args.join(',')})") + end + }, + output: <<~OUTPUT.strip, + Missing: foo() + Missing: foo(true) + Missing: foo(2,true) + Missing: foo(true) + rendered + OUTPUT + }, + + "hook-helper-missing-default-no-param" => { + pending: "We need to improve the compatibility of helper method missing behavior.", + template: <<~TEMPLATE.strip, + some_{{foo}}mustache + some_{{#foo}}abc{{/foo}}block + TEMPLATE + output: <<~OUTPUT.strip, + some_mustache + some_block + OUTPUT + }, + + "hook-helper-missing-default-param" => { + template: <<~TEMPLATE.strip, + {{foo bar}} + {{#foo bar}}abc{{/foo}} + TEMPLATE + error: [Handlebars::UnknownHelper, "Helper \"foo\" does not exist"] + }, + + "hook-block-helper-missing" => { + pending: "Implement the blockHelperMissing helper and behavior.", + template: <<~TEMPLATE.strip, + {{#person}} + {{firstname}} {{lastname}} + {{/person}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz", + }, + }, + helpers: { + blockHelperMissing: Proc.new do |context, block:, **options| + %{Helper '#{options.name}' not found. Printing block: #{block.fn(context)}} + end + }, + output: <<~OUTPUT.strip, + Helper 'person' not found. Printing block: Yehuda Katz + OUTPUT + }, + + "hook-block-helper-missing-default" => { + pending: "Implement the blockHelperMissing helper and behavior.", + template: <<~TEMPLATE.strip, + {{#person}} + {{firstname}} {{lastname}} + {{/person}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz", + }, + }, + output: <<~OUTPUT.strip, + Yehuda Katz + OUTPUT + }, + + "hook-block-helper-missing-default-param" => { + # TODO: This causes a lookup exception in handlebars. + # Should it work here? + template: <<~TEMPLATE.strip, + {{#person foo}} + {{firstname}} {{lastname}} + {{/person}} + TEMPLATE + input: { + person: { + firstname: "Yehuda", + lastname: "Katz", + }, + }, + output: "\n Yehuda Katz\n" + }, + } + + EXAMPLES.each do |example_name, scenario| + puts example_name + it "passes on the #{example_name} scenario" do + pending(scenario[:pending]) if scenario[:pending] + action = Proc.new do + render(scenario[:template], scenario[:input]) do |renderer| + scenario[:partials]&.each { |name, content| renderer.register_partial(name, content) } + scenario[:helpers]&.each { |name, proc| renderer.register_helper(name, &proc) } + end + end + + if scenario[:error] + expect(action).to raise_error(*scenario[:error]) + else + expect(scenario[:output]).to eq(action.call) + end + end + end + + def render(template, input = {}) + compiled_template = renderer.compile(template) + yield renderer, compiled_template if block_given? + compiled_template.call(input) + end +end diff --git a/spec/parser_spec.rb b/spec/parser_spec.rb index de5dee5..a3d09ab 100644 --- a/spec/parser_spec.rb +++ b/spec/parser_spec.rb @@ -98,7 +98,7 @@ it 'comments' do expect(parser.parse('{{! this is a comment }}')).to eq( block_items: [ - collapse_options.merge(comment: ' this is a comment ') + collapse_options.merge(comment: 'this is a comment ') ] ) end diff --git a/spec/ruby-handlebars/helpers/each_helper_spec.rb b/spec/ruby-handlebars/helpers/each_helper_spec.rb index 9b3f86a..47db4dd 100644 --- a/spec/ruby-handlebars/helpers/each_helper_spec.rb +++ b/spec/ruby-handlebars/helpers/each_helper_spec.rb @@ -155,6 +155,15 @@ ].join("\n")) end + it 'can handle array items' do + result = evaluate(<<~TEMPLATE.strip, {first: {second: [{a: :b}]}}) + {{#each first}}{{@key}}{{/each}} + {{#each first.second}}{{@key}}{{/each}} + TEMPLATE + + expect(result).to eq("second\n0") + end + it 'imbricated' do data = {people: [ { diff --git a/spec/ruby-handlebars/helpers/helper_missing_helper_spec.rb b/spec/ruby-handlebars/helpers/helper_missing_helper_spec.rb index 598b80d..3c8ee56 100644 --- a/spec/ruby-handlebars/helpers/helper_missing_helper_spec.rb +++ b/spec/ruby-handlebars/helpers/helper_missing_helper_spec.rb @@ -17,7 +17,7 @@ let(:name) { "missing_helper" } it 'raises a Handlebars::UnknownHelper exception with the name given as a parameter' do - expect { subject.apply(ctx, name) }.to raise_exception( + expect { subject.apply(ctx, name: name) }.to raise_exception( Handlebars::UnknownHelper, "Helper \"#{name}\" does not exist" ) diff --git a/spec/ruby-handlebars/helpers/register_default_helpers_spec.rb b/spec/ruby-handlebars/helpers/register_default_helpers_spec.rb index 62fe841..7c7ec9c 100644 --- a/spec/ruby-handlebars/helpers/register_default_helpers_spec.rb +++ b/spec/ruby-handlebars/helpers/register_default_helpers_spec.rb @@ -9,26 +9,19 @@ it 'registers the default helpers' do hbs = double(Handlebars::Handlebars) allow(hbs).to receive(:register_helper) - allow(hbs).to receive(:register_as_helper) - Handlebars::Helpers.register_default_helpers(hbs) - expect(hbs) - .to have_received(:register_helper) - .once - .with('if') - .once - .with('unless') - .once - .with('each') - .once - .with('helperMissing') - - expect(hbs) - .to have_received(:register_as_helper) - .once - .with('each') + expect(hbs).to have_received(:register_helper) + .once.with('if', as: false) + .once.with('unless', as: false) + .once.with('lookup', as: false) + .once.with('each', as: false) + .once.with('each', as: true) + .once.with('helperMissing', as: true) + .once.with('helperMissing', as: false) + .once.with('with', as: false) + .once.with('with', as: true) end end end diff --git a/spec/ruby-handlebars/helpers/shared.rb b/spec/ruby-handlebars/helpers/shared.rb index ef19d13..162981f 100644 --- a/spec/ruby-handlebars/helpers/shared.rb +++ b/spec/ruby-handlebars/helpers/shared.rb @@ -21,14 +21,11 @@ def evaluate(template, args = {}) it "registers the \"#{name}\" helper" do hbs = double(Handlebars::Handlebars) allow(hbs).to receive(:register_helper) - allow(hbs).to receive(:register_as_helper) subject.register(hbs) - expect(hbs) - .to have_received(:register_helper) - .once - .with(name) + expect(hbs).to have_received(:register_helper).with(name, as: false) if subject.respond_to?(:apply) + expect(hbs).to have_received(:register_helper).with(name, as: true) if subject.respond_to?(:apply_as) end end