diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fbe12..71a9b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: - ruby-2.6 - ruby-2.7 - ruby-3.0 + - ruby-3.1 # - jruby # - truffleruby runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 08d3021..80e53ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /[._]*/ +!/.github/ /doc/ /pkg/ /spec/reports/ diff --git a/.rubocop.yml b/.rubocop.yml index 77c47d4..48a4611 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -119,9 +119,6 @@ Metrics/ClassLength: - heredoc Max: 120 -Metrics/CyclomaticComplexity: - Max: 10 - Metrics/MethodLength: CountAsOne: - array @@ -138,8 +135,14 @@ Metrics/ModuleLength: Metrics/ParameterLists: Max: 10 +RSpec/ExampleLength: + CountAsOne: + - array + - hash + - heredoc + RSpec/MultipleMemoizedHelpers: - Max: 10 + Max: 20 RSpec/NestedGroups: Max: 8 diff --git a/README.md b/README.md index 8ef023b..501bb03 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,144 @@ Or install it yourself as: ## Usage -TODO: Write usage instructions here +### Quick Start + +```ruby +handlebars = Handlebars::Engine.new +template = handlebars.compile("{{firstname}} {{lastname}}") +template.call({ firstname: "Yehuda", lastname: "Katz" }) +# => "Yehuda Katz" +``` + +### Custom Helpers + +Handlebars helpers can be accessed from any context in a template. You can +register a helper with the `register_helper` method: + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_helper(:loud) do |ctx, arg, opts| + arg.upcase +end +template = handlebars.compile("{{firstname}} {{loud lastname}}") +template.call({ firstname: "Yehuda", lastname: "Katz" }) +# => "Yehuda KATZ" +``` + +#### Helper Arguments + +Helpers receive the current context as the first argument of the block. + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_helper(:full_name) do |ctx, opts| + "#{ctx["firstname"]} #{ctx["lastname"]}" +end +template = handlebars.compile("{{full_name}}") +template.call({ firstname: "Yehuda", lastname: "Katz" }) +# => "Yehuda Katz" +``` + +Any arguments to the helper are included as individual positional arguments. + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_helper(:join) do |ctx, *args, opts| + args.join(" ") +end +template = handlebars.compile("{{join firstname lastname}}") +template.call({ firstname: "Yehuda", lastname: "Katz" }) +# => "Yehuda Katz" +``` + +The last argument is a hash of options. + +See https://handlebarsjs.com/guide/#custom-helpers. + +### Block Helpers + +See https://handlebarsjs.com/guide/#block-helpers. + +### Partials + +Handlebars partials allow for code reuse by creating shared templates. + +You can register a partial using the `register_partial` method: + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_partial(:person, "{{person.name}} is {{person.age}}.") +template = handlebars.compile("{{> person person=.}}") +template.call({ name: "Yehuda Katz", age: 20 }) +# => "Yehuda Katz is 20." +``` + +See https://handlebarsjs.com/guide/#partials. +See https://handlebarsjs.com/guide/partials.html. + +### Hooks + +#### Helper Missing + +This hook is called for a mustache or a block-statement when +* a simple mustache-expression is not a registered helper, *and* +* it is not a property of the current evaluation context. + +You can add custom handling for those situations by registering a helper with +the `register_helper_missing` method: + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_helper_missing do |ctx, *args, opts| + "Missing: #{opts["name"]}(#{args.join(", ")})" +end + +template = handlebars.compile("{{foo 2 true}}") +template.call +# => "Missing: foo(2, true)" + +template = handlebars.compile("{{#foo true}}{{/foo}}") +template.call +# => "Missing: foo(true)" +``` + +See https://handlebarsjs.com/guide/hooks.html#helpermissing. + +##### Blocks + +This hook is called for a block-statement when +* a block-expression calls a helper that is not registered, *and* +* the name is a property of the current evaluation context. + +You can add custom handling for those situations by registering a helper with +the `register_helper_missing` method (with a `:block` argument): + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_helper_missing(:block) do |ctx, *args, opts| + "Missing: #{opts["name"]}(#{args.join(", ")})" +end + +template = handlebars.compile("{{#person}}{{name}}{{/person}}") +template.call({ person: { name: "Yehuda Katz" } }) +# => "Missing: person" +``` + +See https://handlebarsjs.com/guide/hooks.html#blockhelpermissing. + +#### Partial Missing + +This hook is called for a partial that is not registered. + +```ruby +handlebars = Handlebars::Engine.new +handlebars.register_partial_missing do |name| + "partial: #{name}" +end +``` + +Note: This is not a part of the offical Handlebars API. It is provided for +convenience. ## Changelog diff --git a/handlebars-engine.gemspec b/handlebars-engine.gemspec index 23dabc3..844cb75 100644 --- a/handlebars-engine.gemspec +++ b/handlebars-engine.gemspec @@ -35,4 +35,7 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + + spec.add_dependency "handlebars-source" + spec.add_dependency "mini_racer" end diff --git a/lib/handlebars/engine.rb b/lib/handlebars/engine.rb index 2498b5b..102d161 100644 --- a/lib/handlebars/engine.rb +++ b/lib/handlebars/engine.rb @@ -1,9 +1,224 @@ # frozen_string_literal: true +require "handlebars/source" +require "json" +require "mini_racer" +require "securerandom" require_relative "engine/version" module Handlebars # The Handlebars engine. - module Engine + # + # This API follows the JavaScript API as closely as possible: + # https://handlebarsjs.com/api-reference/. + class Engine + # Creates a new instance. + # + # @param lazy [true, false] immediately loads and initializes the JavaScript + # environment. + def initialize(lazy: false) + init! unless lazy + end + + ################################### + # Compilation + ################################### + + # Compiles a template so it can be executed immediately. + # + # @param template [String] the template string to compile + # @param options [Hash] the options + # @return [Proc] the template function to call + # @see https://handlebarsjs.com/api-reference/compilation.html#handlebars-compile-template-options + def compile(*args) + call(__method__, args, assign: true) + end + + # Precompiles a given template so it can be executed without compilation. + # + # @param template [String] the template string to precompiled + # @param options [Hash] the options + # @return [String] the precompiled template spec + # @see https://handlebarsjs.com/api-reference/compilation.html#handlebars-precompile-template-options + def precompile(*args) + call(__method__, args) + end + + # Sets up a template that was precompiled with `precompile`. + # + # @param spec [String] the precompiled template spec + # @return [Proc] the template function to call + # @see #precompile + # @see https://handlebarsjs.com/api-reference/compilation.html#handlebars-template-templatespec + def template(*args) + call(__method__, args, assign: true) + end + + ################################### + # Runtime + ################################### + + # Registers helpers accessible by any template in the environment. + # + # @param name [String, Symbol] the name of the helper + # @yieldparam context [Hash] the current context + # @yieldparam arguments [Object] the arguments (optional) + # @yieldparam options [Hash] the options hash (optional) + # @see https://handlebarsjs.com/api-reference/runtime.html#handlebars-registerhelper-name-helper + def register_helper(name, &block) + attach(name, &block) + call(:registerHelper, [name.to_s, name.to_sym], eval: true) + end + + # Unregisters a previously registered helper. + # + # @param name [String, Symbol] the name of the helper + # @see https://handlebarsjs.com/api-reference/runtime.html#handlebars-unregisterhelper-name + def unregister_helper(name) + call(:unregisterHelper, [name]) + end + + # Registers partials accessible by any template in the environment. + # + # @param name [String, Symbol] the name of the partial + # @param partial [String] the partial template + # @see https://handlebarsjs.com/api-reference/runtime.html#handlebars-registerpartial-name-partial + def register_partial(name = nil, partial = nil, **partials) + partials[name] = partial if name + call(:registerPartial, [partials]) + end + + # Unregisters a previously registered partial. + # + # @param name [String, Symbol] the name of the partial + # @see https://handlebarsjs.com/api-reference/runtime.html#handlebars-unregisterpartial-name + def unregister_partial(name) + call(:unregisterPartial, [name]) + end + + ################################### + # Hooks + ################################### + + # Registers the hook called when a mustache or a block-statement is missing. + # + # @param type [Symbol] the type of hook to register (`:basic` or `:block`) + # @yieldparam arguments [Object] the arguments (optional) + # @yieldparam options [Hash] the options hash (optional) + # @see https://handlebarsjs.com/guide/hooks.html#helpermissing + def register_helper_missing(type = :basic, &block) + name = helper_missing_name(type) + register_helper(name, &block) + end + + # Unregisters the previously registered hook. + # + # @param type [Symbol] the type of hook to register (`:basic` or `:block`) + # @see https://handlebarsjs.com/guide/hooks.html#helpermissing + def unregister_helper_missing(type = :basic) + name = helper_missing_name(type) + unregister_helper(name) + end + + # Registers the hook called when a partial is missing. + # + # Note: This is not a part of the offical Handlebars API. It is provided for + # convenience. + # + # @yieldparam name [String] the name of the undefined partial + def register_partial_missing(&block) + attach(:partialMissing, &block) + end + + # Unregisters the previously registered hook. + def unregister_partial_missing + evaluate("delete partialMissing") + end + + ################################### + # Miscellaneous + ################################### + + # Returns the version of Handlebars. + # + # @return [String] the Handlebars version. + def version + evaluate("VERSION") + end + + ################################### + # Private + ################################### + + private + + def attach(name, &block) + init! + @context.attach(name.to_s, block) + end + + def call(name, args, assign: false, eval: false) + init! + name = name.to_s + + if assign || eval + call_via_eval(name, args, assign: assign) + else + @context.call(name, *args) + end + end + + def call_via_eval(name, args, assign: false) + args = js_args(args) + + var = assign ? "v#{SecureRandom.alphanumeric}" : nil + + code = "#{name}(#{args.join(", ")})" + code = "#{var} = #{code}" if var + + result = evaluate(code) + + if var && result.is_a?(MiniRacer::JavaScriptFunction) + result = ->(*a) { @context.call(var, *a) } + finalizer = ->(*) { evaluate("delete #{var}") } + ObjectSpace.define_finalizer(result, finalizer) + end + + result + end + + def evaluate(code) + @context.eval(code) + end + + def helper_missing_name(type) + case type + when :basic + :helperMissing + when :block + :blockHelperMissing + end + end + + def init! + return if @init + + @context = MiniRacer::Context.new + @context.load(::Handlebars::Source.bundled_path) + @context.load(File.absolute_path("engine/init.js", __dir__)) + + @init = true + end + + def js_args(args) + args.map { |arg| + case arg + when Symbol + arg + else + JSON.generate(arg) + end + } + end end end diff --git a/lib/handlebars/engine/init.js b/lib/handlebars/engine/init.js new file mode 100644 index 0000000..22d1d99 --- /dev/null +++ b/lib/handlebars/engine/init.js @@ -0,0 +1,43 @@ +var { + compile, + precompile, + registerPartial, + unregisterPartial, + registerHelper, + unregisterHelper, + VERSION, +} = Handlebars; + +var template = (spec) => { + eval(`spec = ${spec}`); + return Handlebars.template(spec); +}; + +var registerPartial = Handlebars.registerPartial.bind(Handlebars); +var unregisterPartial = Handlebars.unregisterPartial.bind(Handlebars); + +var registerHelper = (...args) => { + const fn = args[args.length - 1]; + function wrapper(...args) { + args.unshift(this); + return fn(...args); + } + args[args.length - 1] = wrapper; + return Handlebars.registerHelper(...args); +}; + +var unregisterHelper = Handlebars.unregisterHelper.bind(Handlebars); + +var partialMissing; + +const partialsHandler = { + get(partials, name) { + const partial = partials[name] ?? partialMissing?.(name); + if (partial) { + partials[name] = partial; + } + return partial; + }, +}; + +Handlebars.partials = new Proxy(Handlebars.partials, partialsHandler); diff --git a/lib/handlebars/engine/version.rb b/lib/handlebars/engine/version.rb index e46cdd6..b157f90 100644 --- a/lib/handlebars/engine/version.rb +++ b/lib/handlebars/engine/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Handlebars - module Engine + class Engine VERSION = "0.1.0" end end diff --git a/spec/handlebars/engine_spec.rb b/spec/handlebars/engine_spec.rb index 08e7078..a894ea0 100644 --- a/spec/handlebars/engine_spec.rb +++ b/spec/handlebars/engine_spec.rb @@ -1,7 +1,512 @@ # frozen_string_literal: true RSpec.describe Handlebars::Engine do + let(:engine) { described_class.new(**engine_options) } + let(:engine_context) { engine.instance_variable_get(:@context) } + let(:engine_options) { {} } + let(:render) { renderer.call(render_context, render_options) } + let(:render_context) { { name: "Zach", age: 30 } } + let(:render_options) { {} } + let(:rendered) { "Hello, Zach!" } + let(:renderer) { engine.compile(template, template_options) } + let(:template) { "Hello, {{name}}!" } + let(:template_options) { nil } + it "has a version number" do - expect(Handlebars::Engine::VERSION).not_to be(nil) + expect(Handlebars::Engine::VERSION).to match(/^\d+\.\d+\.\d+(-.+)?(\+.+)?/) + end + + describe "#initialize" do + context "when `lazy` is `false`" do + before do + engine_options[:lazy] = false + end + + it "creates the context" do + expect(engine_context).to be_a(MiniRacer::Context) + end + + it "loads Handlebars" do + handlebars = engine_context.eval("!!Handlebars") + expect(handlebars).to be(true) + end + end + + context "when `lazy` is `true`" do + before do + engine_options[:lazy] = true + end + + it "does not create the context" do + expect(engine_context).to be nil + end + end + end + + ################################### + # Compilation + ################################### + + shared_examples "renderer" do + describe "#call" do + it "is defined" do + expect(renderer).to respond_to(:call).with(0..2).arguments + end + + include_examples "rendering" + end + end + + shared_examples "rendering" do |error: false| + if error + it "raises an error" do + expect { render }.to raise_error(MiniRacer::RuntimeError) + end + else + it "renders the template" do + expect(render).to eq(rendered) + end + end + end + + describe "#compile" do + let(:renderer) { engine.compile(template, template_options) } + + it "is defined" do + expect(engine).to respond_to(:compile).with(1..2).arguments + end + + describe "return value" do + it_behaves_like "renderer" + end + end + + describe "#precompile" do + let(:spec) { engine.precompile(template, template_options) } + + it "is defined" do + expect(engine).to respond_to(:precompile).with(1..2).arguments + end + + describe "return value" do + it "is a string" do + expect(spec).to be_a(String) + end + end + end + + describe "#template" do + let(:renderer) { engine.template(template_spec) } + let(:template_spec) { engine.precompile(template, template_options) } + + it "is defined" do + expect(engine).to respond_to(:template).with(1).argument + end + + describe "return value" do + it_behaves_like "renderer" + end + end + + ################################### + # Runtime + ################################### + + describe "#register_helper" do + let(:name) { :helper } + let(:function) { ->(ctx, *args, opts) {} } + let(:template) { "{{#{name} name name=name}}" } + + before do + allow(function).to receive(:call).with(any_args).and_call_original + engine.register_helper(name, &function) + end + + it "is defined" do + expect(engine).to respond_to(:register_helper) + end + + describe "rendering" do + describe "the first parameter" do + it "is the context" do + render_context.transform_keys!(&:to_s) + args = [render_context, any_args, anything] + render + expect(function).to have_received(:call).with(*args) + end + end + + describe "the middle parameter(s)" do + it "is the positional argument(s)" do + args = [anything, *render_context.values_at(:name), anything] + render + expect(function).to have_received(:call).with(*args) + end + end + + describe "the last parameter" do + it "is the options" do + opts = include( + "data" => kind_of(Hash), + "hash" => { "name" => render_context[:name] }, + "name" => name.to_s, + ) + args = [anything, any_args, opts] + render + expect(function).to have_received(:call).with(*args) + end + end + + context "with a block helper" do + let(:template) { "{{##{name}}}help{{/#{name}}}" } + + describe "the options" do + it "includes the main block function" do + opts = include( + "fn" => kind_of(MiniRacer::JavaScriptFunction), + ) + args = [anything, any_args, opts] + render + expect(function).to have_received(:call).with(*args) + end + + it "includes the else block function" do + opts = include( + "inverse" => kind_of(MiniRacer::JavaScriptFunction), + ) + args = [anything, any_args, opts] + render + expect(function).to have_received(:call).with(*args) + end + end + end + end + end + + describe "#unregister_helper" do + let(:name) { :helper } + let(:function) { ->(ctx, *args, opts) {} } + let(:rendered) { "Missing helper: \"#{name}\"" } + let(:template) { "{{#{name} name name=name}}" } + + before do + engine.register_helper(name, &function) + engine.unregister_helper(name) + end + + it "is defined" do + expect(engine).to respond_to(:unregister_helper) + end + + describe "rendering" do + include_examples "rendering", error: true + end + end + + describe "#register_partial" do + let(:name) { :person } + let(:partial) { "{{p.name}} is {{p.age}}" } + let(:rendered) { "Zach is 30" } + let(:template) { "{{> person p=.}}" } + + it "is defined" do + expect(engine).to respond_to(:register_partial) + end + + context "with positional parameters" do + before do + engine.register_partial(name, partial) + end + + describe "rendering" do + include_examples "rendering" + end + end + + context "with keyword parameters" do + before do + engine.register_partial(name => partial) + end + + describe "rendering" do + include_examples "rendering" + end + end + end + + describe "#unregister_partial" do + let(:name) { :person } + let(:partial) { "" } + let(:template) { "{{> person}}" } + + before do + engine.register_partial(name, partial) + engine.unregister_partial(name) + end + + it "is defined" do + expect(engine).to respond_to(:unregister_partial) + end + + describe "rendering" do + include_examples "rendering", error: true + end + end + + ################################### + # Hooks + ################################### + + describe "#register_helper_missing" do + let(:name) { :helper } + let(:function) { ->(_ctx, *_args, opts) { "Missing: #{opts["name"]}" } } + let(:rendered) { "Missing: #{name}" } + let(:template) { "{{#{name} name}}" } + let(:type) { :basic } + + before do + allow(function).to receive(:call).with(any_args).and_call_original + engine.register_helper_missing(type, &function) + end + + it "is defined" do + expect(engine).to respond_to(:register_helper_missing) + end + + describe "rendering" do + describe "the first parameter" do + it "is the context" do + render_context.transform_keys!(&:to_s) + args = [render_context, any_args, anything] + render + expect(function).to have_received(:call).with(*args) + end + end + + describe "the middle parameter(s)" do + it "is the positional argument(s)" do + args = [anything, *render_context.values_at(:name), anything] + render + expect(function).to have_received(:call).with(*args) + end + end + + describe "the last parameter" do + it "is the options" do + opts = include( + "data" => kind_of(Hash), + "hash" => {}, + "name" => name.to_s, + ) + args = [any_args, opts] + render + expect(function).to have_received(:call).with(*args) + end + end + + include_examples "rendering" + + context "when called as a block" do + let(:block_args) { [] } + let(:template) { "{{##{name} #{block_args.join(" ")}}}{{/#{name}}}" } + + context "with no arguments" do + let(:block_args) { [] } + + context "with no matching context value" do + before do + render_context.delete(name) + end + + it "calls the helper" do + render + expect(function).to have_received(:call) + end + end + + context "with a matching context value" do + before do + render_context[name] = "missing" + end + + it "does not call the helper" do + render + expect(function).not_to have_received(:call) + end + end + end + + context "with some arguments" do + let(:block_args) { ["name"] } + + context "with no matching context value" do + before do + render_context.delete(name) + end + + it "calls the helper" do + render + expect(function).to have_received(:call) + end + end + + context "with a matching context value" do + before do + render_context[name] = "missing" + end + + include_examples "rendering", error: true + end + end + end + + describe "type: `block`" do + let(:type) { :block } + + include_examples "rendering", error: true + + context "when called as a block" do + let(:block_args) { [] } + let(:template) { "{{##{name} #{block_args.join(" ")}}}{{/#{name}}}" } + + context "with no arguments" do + let(:block_args) { [] } + + context "with no matching context value" do + before do + render_context.delete(name) + end + + it "calls the helper" do + render + expect(function).to have_received(:call) + end + end + + context "with a matching context value" do + before do + render_context[name] = "missing" + end + + it "calls the helper" do + render + expect(function).to have_received(:call) + end + end + end + + context "with some arguments" do + let(:block_args) { ["name"] } + + context "with no matching context value" do + before do + render_context.delete(name) + end + + include_examples "rendering", error: true + end + + context "with a matching context value" do + before do + render_context[name] = "missing" + end + + include_examples "rendering", error: true + end + end + end + end + end + end + + describe "#unregister_helper_missing" do + let(:name) { :helper } + let(:function) { ->(ctx, *args, opts) {} } + + before do + engine.register_helper_missing(&function) + engine.unregister_helper_missing + end + + it "is defined" do + expect(engine).to respond_to(:unregister_helper_missing) + end + + describe "rendering" do + context "with arguments" do + let(:template) { "{{#{name} name name=name}}" } + + include_examples "rendering", error: true + end + + context "without arguments" do + let(:rendered) { "" } + let(:template) { "{{#{name}}}" } + + include_examples "rendering" + end + end + end + + describe "#register_partial_missing" do + let(:name) { :person } + let(:function) { ->(_name) { partial } } + let(:partial) { "{{p.name}} is {{p.age}}" } + let(:rendered) { "Zach is 30" } + let(:template) { "{{> #{name} p=.}}" } + + before do + allow(function).to receive(:call).with(any_args).and_call_original + engine.register_partial_missing(&function) + end + + it "is defined" do + expect(engine).to respond_to(:register_partial_missing) + end + + describe "rendering" do + describe "the first parameter" do + it "is the name" do + args = [name.to_s] + render + expect(function).to have_received(:call).with(*args) + end + end + + include_examples "rendering" + end + end + + describe "#unregister_partial_missing" do + let(:name) { :person } + let(:function) { ->(n) {} } + let(:template) { "{{> #{name} p=.}}" } + + before do + engine.register_partial_missing(&function) + engine.unregister_partial_missing + end + + it "is defined" do + expect(engine).to respond_to(:unregister_partial_missing) + end + + describe "rendering" do + include_examples "rendering", error: true + end + end + + ################################### + # Miscellaneous + ################################### + + describe "#version" do + it "is defined" do + expect(engine).to respond_to(:version) + end + + it "returns a version string" do + expect(engine.version).to match(/^\d+\.\d+\.\d+$/) + end end end