├── spec ├── dummy │ ├── .gitignore │ ├── app │ │ ├── views │ │ │ ├── dashboards │ │ │ │ ├── _item.html.curly │ │ │ │ ├── partials.html.curly │ │ │ │ ├── show.html.curly │ │ │ │ ├── new.html.curly │ │ │ │ └── collection.html.curly │ │ │ └── layouts │ │ │ │ └── application.html.curly │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── dashboards_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ └── presenters │ │ │ ├── dashboards │ │ │ ├── collection_presenter.rb │ │ │ ├── partials_presenter.rb │ │ │ ├── show_presenter.rb │ │ │ ├── item_presenter.rb │ │ │ └── new_presenter.rb │ │ │ └── layouts │ │ │ └── application_presenter.rb │ ├── config.ru │ ├── config │ │ ├── environment.rb │ │ ├── routes.rb │ │ ├── boot.rb │ │ ├── application.rb │ │ └── environments │ │ │ └── test.rb │ └── spec │ │ └── presenters │ │ └── dashboards │ │ └── show_presenter_spec.rb ├── syntax_error_spec.rb ├── integration │ ├── application_layout_spec.rb │ ├── partials_spec.rb │ ├── collection_blocks_spec.rb │ └── context_blocks_spec.rb ├── matchers │ └── have_structure.rb ├── component_scanner_spec.rb ├── components_spec.rb ├── conditional_blocks_spec.rb ├── generators │ ├── install_generator_spec.rb │ ├── controller_generator_spec.rb │ ├── scaffold_curly_generator_spec.rb │ └── scaffold_presenter_generator_spec.rb ├── attribute_scanner_spec.rb ├── spec_helper.rb ├── collection_blocks_spec.rb ├── parser_spec.rb ├── compiler │ ├── context_blocks_spec.rb │ └── collections_spec.rb ├── scanner_spec.rb ├── component_compiler_spec.rb ├── compiler_spec.rb ├── template_handler_spec.rb └── presenter_spec.rb ├── lib ├── curly-templates.rb ├── curly │ ├── version.rb │ ├── error.rb │ ├── incomplete_block_error.rb │ ├── incorrect_ending_error.rb │ ├── dependency_tracker.rb │ ├── invalid_component.rb │ ├── rspec.rb │ ├── presenter_name_error.rb │ ├── presenter_not_found.rb │ ├── syntax_error.rb │ ├── component_scanner.rb │ ├── railtie.rb │ ├── attribute_scanner.rb │ ├── template_handler.rb │ ├── component_compiler.rb │ ├── parser.rb │ ├── scanner.rb │ ├── compiler.rb │ └── presenter.rb ├── generators │ ├── curly │ │ ├── controller │ │ │ ├── templates │ │ │ │ ├── view.html.curly.erb │ │ │ │ └── presenter.rb.erb │ │ │ └── controller_generator.rb │ │ ├── scaffold │ │ │ ├── templates │ │ │ │ ├── new.html.curly.erb │ │ │ │ ├── edit.html.curly.erb │ │ │ │ ├── show.html.curly.erb │ │ │ │ ├── new_presenter.rb.erb │ │ │ │ ├── index.html.curly.erb │ │ │ │ ├── edit_presenter.rb.erb │ │ │ │ ├── _form.html.curly.erb │ │ │ │ ├── show_presenter.rb.erb │ │ │ │ ├── index_presenter.rb.erb │ │ │ │ └── form_presenter.rb.erb │ │ │ └── scaffold_generator.rb │ │ └── install │ │ │ ├── templates │ │ │ ├── layout.html.curly.erb │ │ │ └── layout_presenter.rb.erb │ │ │ └── install_generator.rb │ └── curly.rb ├── rails │ └── projections.json └── curly.rb ├── .rspec ├── Gemfile ├── .yardopts ├── .github ├── CODEOWNERS └── workflows │ ├── codeql.yaml │ ├── publish.yml │ ├── rails_main_testing.yml │ └── ci.yml ├── gemfiles ├── rails6.1.gemfile ├── rails7.0.gemfile ├── rails7.1.gemfile ├── rails7.2.gemfile ├── rails8.0.gemfile ├── rails_main.gemfile ├── common.rb ├── rails7.0.gemfile.lock ├── rails6.1.gemfile.lock ├── rails7.1.gemfile.lock ├── rails8.0.gemfile.lock └── rails7.2.gemfile.lock ├── Rakefile ├── .gitignore ├── perf ├── component_benchmark.rb ├── compile_profile.rb └── compile_benchmark.rb ├── curly-templates.gemspec ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /spec/dummy/.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | -------------------------------------------------------------------------------- /lib/curly-templates.rb: -------------------------------------------------------------------------------- 1 | require 'curly' 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -r spec_helper 2 | --order random 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'gemfiles/rails6.1.gemfile' 2 | -------------------------------------------------------------------------------- /lib/curly/version.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | VERSION = "3.4.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/views/dashboards/_item.html.curly: -------------------------------------------------------------------------------- 1 |
  • {{item}} ({{name}})
  • 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --plugin yard-tomdoc 2 | --markup-provider=redcarpet 3 | --markup=markdown 4 | -------------------------------------------------------------------------------- /lib/curly/error.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class Error < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/views/dashboards/partials.html.curly: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /lib/curly/incomplete_block_error.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class IncompleteBlockError < Error 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/curly/incorrect_ending_error.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class IncorrectEndingError < Error 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/views/dashboards/show.html.curly: -------------------------------------------------------------------------------- 1 |

    Dashboard

    2 |

    {{message}}

    3 |

    {{welcome}}

    4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def welcome_message 3 | "Welcome!" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/curly/controller/templates/view.html.curly.erb: -------------------------------------------------------------------------------- 1 |

    <%= class_name %>#<%= @action %>

    2 |

    Find me in <%= @view_path %>

    3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS file 2 | # This file defines who should review code changes in this repository. 3 | 4 | * @zendesk/core-gem-owners 5 | -------------------------------------------------------------------------------- /gemfiles/rails6.1.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', '~> 6.1.0' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-7' 5 | -------------------------------------------------------------------------------- /gemfiles/rails7.0.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', '~> 7.0.0' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-7' 5 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', '~> 7.1.0' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-7' 5 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', '~> 7.2.0' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-7' 5 | -------------------------------------------------------------------------------- /gemfiles/rails8.0.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', '~> 8.0.0' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-8' 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile 'common.rb' 2 | 3 | gem 'rails', github: 'rails/rails', branch: 'main' 4 | gem 'genspec', github: 'zendesk/genspec', branch: 'rails-8' 5 | -------------------------------------------------------------------------------- /spec/dummy/app/views/dashboards/new.html.curly: -------------------------------------------------------------------------------- 1 | {{@form}} 2 | {{@name_field}} 3 | {{label}} {{input}} 4 | {{/name_field}} 5 | {{/form}} 6 | 7 |

    Thank you!

    8 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/new.html.curly.erb: -------------------------------------------------------------------------------- 1 |

    New <%= singular_table_name.titleize %>

    2 | 3 | {{<%= singular_table_name %>_form}} 4 | 5 | {{<%= index_helper %>_link}} -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/edit.html.curly.erb: -------------------------------------------------------------------------------- 1 |

    Editing <%= singular_table_name.titleize %>

    2 | 3 | {{<%= singular_table_name %>_form}} 4 | 5 | {{<%= index_helper %>_link}} -------------------------------------------------------------------------------- /spec/dummy/app/views/dashboards/collection.html.curly: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/app/presenters/dashboards/collection_presenter.rb: -------------------------------------------------------------------------------- 1 | class Dashboards::CollectionPresenter < Curly::Presenter 2 | presents :items, :name 3 | 4 | def items 5 | @items 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/presenters/dashboards/partials_presenter.rb: -------------------------------------------------------------------------------- 1 | class Dashboards::PartialsPresenter < Curly::Presenter 2 | def items 3 | render partial: 'item', collection: ["One", "Two"], locals: { name: "yo" } 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/curly/dependency_tracker.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class DependencyTracker 3 | def self.call(path, template) 4 | presenter = Curly::Presenter.presenter_for_path(path) 5 | presenter.dependencies.to_a 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root to: "dashboards#show" 3 | get "/collection", to: "dashboards#collection" 4 | get "/partials", to: "dashboards#partials" 5 | get "/new", to: "dashboards#new" 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.curly: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{title}} 4 | 5 | 6 | {{@header}}
    7 |

    {{title}}

    8 |
    {{/header}} 9 | {{content}} 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'action_controller/railtie' 4 | 5 | Bundler.require(*Rails.groups) 6 | require "curly" 7 | 8 | module Dummy 9 | class Application < Rails::Application 10 | end 11 | end 12 | 13 | -------------------------------------------------------------------------------- /spec/dummy/app/presenters/dashboards/show_presenter.rb: -------------------------------------------------------------------------------- 1 | class Dashboards::ShowPresenter < Curly::Presenter 2 | presents :message 3 | 4 | def message 5 | @message 6 | end 7 | 8 | def welcome 9 | # This is a helper method: 10 | welcome_message 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/curly/invalid_component.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class InvalidComponent < Error 3 | attr_reader :component 4 | 5 | def initialize(component) 6 | @component = component 7 | end 8 | 9 | def message 10 | "invalid component `{{#{component}}}'" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | InstalledFiles 7 | gemfiles/rails_main.gemfile.lock 8 | lib/bundler/man 9 | pkg 10 | rdoc 11 | spec/reports 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | 16 | # YARD artifacts 17 | .yardoc 18 | _yardoc 19 | doc/ 20 | Gemfile.lock 21 | /bin 22 | -------------------------------------------------------------------------------- /lib/generators/curly/install/templates/layout.html.curly.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= app_name.titleize %> 5 | {{csrf_meta_tags}} 6 | 7 | {{stylesheet_links}} 8 | {{javascript_links}} 9 | 10 | 11 | 12 | {{yield}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /gemfiles/common.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../' 4 | 5 | platform :ruby do 6 | gem 'yard' 7 | gem 'yard-tomdoc' 8 | gem 'redcarpet' 9 | gem 'github-markup' 10 | gem 'rspec-rails', require: false 11 | gem 'benchmark-ips', require: false 12 | gem 'stackprof', require: false 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/dashboards_controller.rb: -------------------------------------------------------------------------------- 1 | class DashboardsController < ApplicationController 2 | def show 3 | @message = "Hello, World!" 4 | end 5 | 6 | def collection 7 | @name = "numbers" 8 | @items = ["uno", "dos", "tres!"] 9 | end 10 | 11 | def new 12 | @name = "test" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/curly.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/named_base" 2 | 3 | module Curly # :nodoc: 4 | module Generators # :nodoc: 5 | class Base < Rails::Generators::NamedBase #:nodoc: 6 | private 7 | 8 | def format 9 | :html 10 | end 11 | 12 | def handler 13 | :curly 14 | end 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /spec/dummy/app/presenters/layouts/application_presenter.rb: -------------------------------------------------------------------------------- 1 | class Layouts::ApplicationPresenter < Curly::Presenter 2 | def title 3 | "Dummy app" 4 | end 5 | 6 | def content 7 | yield 8 | end 9 | 10 | def header(&block) 11 | block.call 12 | end 13 | 14 | class HeaderPresenter < Curly::Presenter 15 | 16 | def title 17 | "Dummy app" 18 | end 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/show.html.curly.erb: -------------------------------------------------------------------------------- 1 |

    {{notice_text}}

    2 | 3 | {{*<%= singular_table_name %>}} 4 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 5 |

    6 | <%= attribute.human_name %>: 7 | {{<%= attribute.name %>}} 8 |

    9 | <% end -%> 10 | {{edit_link}} 11 | {{/<%= singular_table_name %>}} 12 | {{<%= index_helper %>_link}} -------------------------------------------------------------------------------- /spec/dummy/spec/presenters/dashboards/show_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require_relative '../../../config/environment' 3 | require 'curly/rspec' 4 | 5 | describe Dashboards::ShowPresenter, type: :presenter do 6 | describe "#message" do 7 | it "returns the message" do 8 | assign :message, "Hello, World!" 9 | expect(presenter.message).to eq "Hello, World!" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/curly/controller/templates/presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= @presenter_name %> < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | end 11 | -------------------------------------------------------------------------------- /lib/curly/rspec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/rails' 2 | 3 | module Curly 4 | module RSpec 5 | module PresenterExampleGroup 6 | extend ActiveSupport::Concern 7 | include ::RSpec::Rails::ViewExampleGroup 8 | 9 | included do 10 | let(:presenter) { described_class.new(view, view_assigns) } 11 | end 12 | end 13 | 14 | ::RSpec.configuration.include PresenterExampleGroup, type: :presenter 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/dummy/app/presenters/dashboards/item_presenter.rb: -------------------------------------------------------------------------------- 1 | class Dashboards::ItemPresenter < Curly::Presenter 2 | presents :item, :name 3 | 4 | def item 5 | @item 6 | end 7 | 8 | def name 9 | @name 10 | end 11 | 12 | def subitems 13 | %w[1 2 3] 14 | end 15 | 16 | class SubitemPresenter < Curly::Presenter 17 | presents :item, :subitem 18 | 19 | def name 20 | @subitem 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/curly/presenter_name_error.rb: -------------------------------------------------------------------------------- 1 | require 'curly/error' 2 | 3 | module Curly 4 | class PresenterNameError < Error 5 | attr_reader :original_exception, :name 6 | 7 | def initialize(original_exception, name) 8 | @name = name 9 | @original_exception = original_exception 10 | end 11 | 12 | def message 13 | "cannot use context `#{name}`, could not find matching presenter class" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/syntax_error_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::SyntaxError, "#message" do 2 | it "includes the context of the error in the message" do 3 | source = "I am a very bad error that has snuck in" 4 | error = Curly::SyntaxError.new(13, source) 5 | 6 | expect(error.message).to eq <<-MESSAGE.strip_heredoc 7 | invalid syntax near `a very bad error` on line 1 in template: 8 | 9 | I am a very bad error that has snuck in 10 | MESSAGE 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/curly/presenter_not_found.rb: -------------------------------------------------------------------------------- 1 | require 'curly/error' 2 | 3 | module Curly 4 | class PresenterNotFound < Error 5 | attr_reader :path 6 | 7 | def initialize(path) 8 | @path = path 9 | end 10 | 11 | def message 12 | "error compiling `#{path}`: could not find #{presenter_class_name}" 13 | end 14 | 15 | private 16 | 17 | def presenter_class_name 18 | Curly::Presenter.presenter_name_for_path(path) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | name: "CodeQL public repository scanning" 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | pull_request_target: 8 | types: [opened, synchronize, reopened] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | security-events: write 14 | actions: read 15 | packages: read 16 | 17 | jobs: 18 | trigger-codeql: 19 | uses: zendesk/prodsec-code-scanning/.github/workflows/codeql_advanced_shared.yml@production 20 | -------------------------------------------------------------------------------- /lib/rails/projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "app/presenters/*_presenter.rb": { 3 | "affinity": "controller", 4 | "command": "presenter", 5 | "test": "spec/presenters/%s_presenter_spec.rb", 6 | "related": "app/views/%s.html.curly", 7 | "template": "class %SPresenter < Curly::Presenter\nend", 8 | "keywords": "presents depends_on version" 9 | }, 10 | 11 | "app/views/*.html.curly": { 12 | "affinity": "controller", 13 | "test": "spec/views/%s_spec.rb", 14 | "related": "app/presenters/%s_presenter.rb" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/curly/syntax_error.rb: -------------------------------------------------------------------------------- 1 | require 'curly/error' 2 | 3 | module Curly 4 | class SyntaxError < Error 5 | def initialize(position, source) 6 | @position, @source = position, source 7 | end 8 | 9 | def message 10 | start = [@position - 8, 0].max 11 | stop = [@position + 8, @source.length].min 12 | snippet = @source[start..stop].strip 13 | line = @source[0..@position].count("\n") + 1 14 | "invalid syntax near `#{snippet}` on line #{line} in " \ 15 | "template:\n\n#{@source}\n" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/integration/application_layout_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Using Curly for the application layout", type: :request do 2 | example "A simple layout view" do 3 | get '/' 4 | 5 | expect(response.body).to eq <<-HTML.strip_heredoc 6 | 7 | 8 | Dummy app 9 | 10 | 11 |
    12 |

    Dummy app

    13 |
    14 |

    Dashboard

    15 |

    Hello, World!

    16 |

    Welcome!

    17 | 18 | 19 | 20 | HTML 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/integration/partials_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Using Curly for Rails partials", type: :request do 2 | example "Rendering a partial" do 3 | get '/partials' 4 | 5 | expect(response.body).to eq <<-HTML.strip_heredoc 6 | 7 | 8 | Dummy app 9 | 10 | 11 |
    12 |

    Dummy app

    13 |
    14 | 19 | 20 | 21 | 22 | HTML 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/curly/component_scanner.rb: -------------------------------------------------------------------------------- 1 | require 'curly/attribute_scanner' 2 | 3 | module Curly 4 | class ComponentScanner 5 | def self.scan(component) 6 | first, rest = component.strip.split(/\s+/, 2) 7 | contexts = first.split(":") 8 | name_and_identifier = contexts.pop 9 | 10 | name, identifier = name_and_identifier.split(".", 2) 11 | 12 | if identifier && identifier.end_with?("?") 13 | name += "?" 14 | identifier = identifier[0..-2] 15 | end 16 | 17 | attributes = AttributeScanner.scan(rest) 18 | 19 | [name, identifier, attributes, contexts] 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/curly/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'curly/dependency_tracker' 2 | 3 | module Curly 4 | class Railtie < Rails::Railtie 5 | config.app_generators.template_engine :curly 6 | 7 | initializer 'curly.initialize_template_handler' do 8 | ActionView::Template.register_template_handler :curly, Curly::TemplateHandler 9 | 10 | if defined?(CacheDigests::DependencyTracker) 11 | CacheDigests::DependencyTracker.register_tracker :curly, Curly::DependencyTracker 12 | end 13 | 14 | if defined?(ActionView::DependencyTracker) 15 | ActionView::DependencyTracker.register_tracker :curly, Curly::DependencyTracker 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/app/presenters/dashboards/new_presenter.rb: -------------------------------------------------------------------------------- 1 | class Dashboards::NewPresenter < Curly::Presenter 2 | presents :name 3 | 4 | def form(&block) 5 | form_for(:dashboard, &block) 6 | end 7 | 8 | class FormPresenter < Curly::Presenter 9 | presents :form, :name 10 | 11 | def name_field(&block) 12 | content_tag :div, class: "field" do 13 | block.call 14 | end 15 | end 16 | 17 | class NameFieldPresenter < Curly::Presenter 18 | presents :form, :name 19 | 20 | def label 21 | "Name" 22 | end 23 | 24 | def input 25 | @form.text_field :name, value: @name 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to RubyGems.org 2 | 3 | on: 4 | push: 5 | branches: main 6 | paths: lib/curly/version.rb 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | environment: rubygems-publish 13 | if: github.repository_owner == 'zendesk' 14 | permissions: 15 | id-token: write 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: false 23 | ruby-version: "3.4" 24 | - name: Install dependencies 25 | run: bundle install 26 | - uses: rubygems/release-gem@v1 27 | -------------------------------------------------------------------------------- /lib/generators/curly/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/resource_helpers" 2 | 3 | module Curly # :nodoc: 4 | module Generators # :nodoc: 5 | class InstallGenerator < Rails::Generators::Base # :nodoc: 6 | 7 | source_root File.expand_path("../templates", __FILE__) 8 | 9 | attr_reader :app_name 10 | 11 | def generate_layout 12 | app = ::Rails.application 13 | @app_name = app.class.to_s.split("::").first 14 | remove_file 'app/views/layouts/application.html.erb' 15 | template "layout.html.curly.erb", "app/views/layouts/application.html.curly" 16 | template "layout_presenter.rb.erb", "app/presenters/layouts/application_presenter.rb" 17 | end 18 | 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/new_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= plural_table_name.capitalize %>::<%= @view_name.capitalize %>Presenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | presents :<%= singular_table_name %> 11 | 12 | def <%= singular_table_name %>_form 13 | render 'form', <%= singular_table_name %>: @<%= singular_table_name %> 14 | end 15 | 16 | def <%= index_helper %>_link 17 | link_to 'Back', <%= index_helper %>_path 18 | end 19 | 20 | end -------------------------------------------------------------------------------- /.github/workflows/rails_main_testing.yml: -------------------------------------------------------------------------------- 1 | name: Test against Rails main 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Run every day at 00:00 UTC 6 | workflow_dispatch: 7 | 8 | jobs: 9 | specs: 10 | name: Ruby ${{ matrix.ruby }} using ${{ matrix.gemfile }} 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: 18 | - '3.3' 19 | gemfile: 20 | - rails_main 21 | steps: 22 | - uses: zendesk/checkout@v4 23 | - uses: zendesk/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - run: bundle exec rspec 28 | -------------------------------------------------------------------------------- /lib/generators/curly/install/templates/layout_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class Layouts::ApplicationPresenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | exposes_helper :csrf_meta_tags 11 | 12 | def yield 13 | yield 14 | end 15 | 16 | def stylesheet_links 17 | stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' 18 | end 19 | 20 | def javascript_links 21 | javascript_include_tag 'application', 'data-turbolinks-track': 'reload' 22 | end 23 | 24 | 25 | end -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/index.html.curly.erb: -------------------------------------------------------------------------------- 1 |

    {{notice_text}}

    2 | 3 |

    <%= plural_table_name.titleize %>

    4 | 5 | 6 | 7 | 8 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 9 | 10 | <% end -%> 11 | 12 | 13 | 14 | 15 | 16 | {{*<%= plural_table_name %>}} 17 | 18 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 19 | 20 | <% end -%> 21 | 22 | 23 | 24 | 25 | {{/<%= plural_table_name %>}} 26 | 27 |
    <%= attribute.human_name %>
    {{<%= attribute.name %>}}{{show_link}}{{edit_link}}{{destroy_link}}
    28 | 29 |
    30 | 31 | {{create_link}} -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/edit_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= plural_table_name.capitalize %>::<%= @view_name.capitalize %>Presenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | presents :<%= singular_table_name %> 11 | 12 | def <%= singular_table_name %> 13 | @<%= singular_table_name %> 14 | end 15 | 16 | def <%= singular_table_name %>_form 17 | render 'form', <%= singular_table_name %>: @<%= singular_table_name %> 18 | end 19 | 20 | def <%= index_helper %>_link 21 | link_to 'Back', <%= index_helper %>_path 22 | end 23 | 24 | end -------------------------------------------------------------------------------- /spec/integration/collection_blocks_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Collection blocks", type: :request do 2 | example "Rendering collections" do 3 | get '/collection' 4 | 5 | expect(response.body).to eq <<-HTML.strip_heredoc 6 | 7 | 8 | Dummy app 9 | 10 | 11 |
    12 |

    Dummy app

    13 |
    14 | 32 | 33 | 34 | 35 | HTML 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/integration/context_blocks_spec.rb: -------------------------------------------------------------------------------- 1 | require 'matchers/have_structure' 2 | 3 | describe "Context blocks", type: :request do 4 | example "A context block" do 5 | get '/new' 6 | 7 | expect(response.body).to have_structure <<-HTML.strip_heredoc 8 | 9 | 10 | Dummy app 11 | 12 | 13 |
    14 |

    Dummy app

    15 |
    16 |
    17 |
    18 | Name 19 |
    20 |
    21 | 22 |

    Thank you!

    23 | 24 | 25 | HTML 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/matchers/have_structure.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/expectations' 2 | require 'nokogiri' 3 | 4 | RSpec::Matchers.define(:have_structure) do |expected| 5 | match do |actual| 6 | expect(normalized(actual)).to eq normalized(expected) 7 | end 8 | 9 | failure_message do |actual| 10 | "Expected\n\n#{actual}\n\n" \ 11 | "to have the same structure as\n\n#{expected}" 12 | end 13 | 14 | failure_message_when_negated do |actual| 15 | "Expected\n\n#{actual}\n\n" \ 16 | "to NOT have the same canonicalized structure as\n\n#{expected}" 17 | end 18 | 19 | def normalized(text) 20 | document = Nokogiri::XML("#{text}") 21 | document.traverse do |node| 22 | node.content = node.text.strip if node.text? 23 | end 24 | 25 | document.canonicalize do |node, parent| 26 | !(node.text? && node.text.empty?) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /perf/component_benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'benchmark/ips' 3 | 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | require_relative '../spec/dummy/config/environment' 7 | 8 | class TestPresenter < Curly::Presenter 9 | def hello_with_delegation 10 | some_helper 11 | end 12 | 13 | def hello_without_delegation 14 | not_some_helper 15 | end 16 | 17 | private 18 | 19 | def not_some_helper 20 | end 21 | end 22 | 23 | class TestContext 24 | def some_helper 25 | end 26 | end 27 | 28 | context = TestContext.new 29 | presenter = TestPresenter.new(context) 30 | 31 | Benchmark.ips do |x| 32 | x.report "presenter method that delegates to the view context" do 33 | presenter.hello_with_delegation 34 | end 35 | 36 | x.report "presenter method that doesn't delegate to the view context" do 37 | presenter.hello_without_delegation 38 | end 39 | 40 | x.compare! 41 | end 42 | -------------------------------------------------------------------------------- /spec/component_scanner_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::ComponentScanner do 2 | it "scans the component name, identifier, and attributes" do 3 | expect(scan('hello.world weather="sunny"')).to eq [ 4 | "hello", 5 | "world", 6 | { "weather" => "sunny" }, 7 | [] 8 | ] 9 | end 10 | 11 | it "allows context namespaces" do 12 | expect(scan('island:beach:hello.world')).to eq [ 13 | "hello", 14 | "world", 15 | {}, 16 | ["island", "beach"] 17 | ] 18 | end 19 | 20 | it "allows a question mark after the identifier" do 21 | expect(scan('hello.world?')).to eq ["hello?", "world", {}, []] 22 | end 23 | 24 | it 'allows spaces before and after component' do 25 | expect(scan(' hello.world weather="sunny" ')).to eq [ 26 | "hello", 27 | "world", 28 | { "weather" => "sunny" }, 29 | [] 30 | ] 31 | end 32 | 33 | def scan(component) 34 | described_class.scan(component) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /curly-templates.gemspec: -------------------------------------------------------------------------------- 1 | require './lib/curly/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'curly-templates' 5 | s.version = Curly::VERSION 6 | 7 | s.summary = "Free your views!" 8 | s.description = "A view layer for your Rails apps that separates structure and logic." 9 | s.license = "apache2" 10 | 11 | s.authors = ["Daniel Schierbeck"] 12 | s.email = 'daniel.schierbeck@gmail.com' 13 | s.homepage = 'https://github.com/zendesk/curly' 14 | 15 | s.require_paths = %w[lib] 16 | 17 | s.rdoc_options = ["--charset=UTF-8"] 18 | 19 | s.required_ruby_version = ">= 3.2" 20 | 21 | s.add_dependency("actionpack", ">= 6.1") 22 | s.add_dependency("sorted_set") 23 | 24 | s.add_development_dependency("rake") 25 | s.add_development_dependency("rspec", ">= 3") 26 | 27 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(perf|spec)/}) } 28 | s.test_files = s.files.select { |path| path =~ /^spec\/.*_spec\.rb/ } 29 | end 30 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/_form.html.curly.erb: -------------------------------------------------------------------------------- 1 | {{@<%= singular_table_name %>_form}} 2 | {{#<%= singular_table_name %>_errors:any?}} 3 |
    4 |

    {{header}}

    5 | 10 |
    11 | {{/<%= singular_table_name %>_errors:any?}} 12 | <% attributes.each do |attribute| -%> 13 |
    14 | <% if attribute.password_digest? -%> 15 | {{label.password}} 16 | {{field field_type="password_field" field_name="password"}} 17 |
    18 | 19 |
    20 | {{label field_name="password_confirmation"}} 21 | {{field field_type="password_field" field_name="password_confirmation"}} 22 | <% else -%> 23 | {{label.<%= attribute.column_name %>}} 24 | {{field field_type="<%= attribute.field_type %>" field_name="<%= attribute.column_name %>"}} 25 | <% end -%> 26 |
    27 | <% end -%> 28 | 29 | {{submit}} 30 | {{/<%= singular_table_name %>_form}} -------------------------------------------------------------------------------- /spec/components_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Components" do 2 | include CompilationSupport 3 | 4 | example "with neither identifier nor attributes" do 5 | define_presenter do 6 | def title 7 | "A Clockwork Orange" 8 | end 9 | end 10 | 11 | expect(render("{{title}}")).to eq "A Clockwork Orange" 12 | end 13 | 14 | example "with an identifier" do 15 | define_presenter do 16 | def reverse(str) 17 | str.reverse 18 | end 19 | end 20 | 21 | expect(render("{{reverse.123}}")).to eq "321" 22 | end 23 | 24 | example "with attributes" do 25 | define_presenter do 26 | def double(number:) 27 | number.to_i * 2 28 | end 29 | end 30 | 31 | expect(render("{{double number=3}}")).to eq "6" 32 | end 33 | 34 | example "with both identifier and attributes" do 35 | define_presenter do 36 | def a(href:, title:) 37 | content_tag :a, nil, href: href, title: title 38 | end 39 | end 40 | 41 | expect(render(%({{a href="/welcome.html" title="Welcome!"}}))).to eq %() 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/conditional_blocks_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Conditional block components" do 2 | include CompilationSupport 3 | 4 | example "with neither identifier nor attributes" do 5 | define_presenter do 6 | def high? 7 | true 8 | end 9 | 10 | def low? 11 | false 12 | end 13 | end 14 | 15 | expect(render("{{#high?}}yup{{/high?}}")).to eq "yup" 16 | expect(render("{{#low?}}nah{{/low?}}")).to eq "" 17 | end 18 | 19 | example "with an identifier" do 20 | define_presenter do 21 | def even?(number) 22 | number.to_i % 2 == 0 23 | end 24 | end 25 | 26 | expect(render("{{#even.42?}}even{{/even.42?}}")).to eq "even" 27 | expect(render("{{#even.13?}}even{{/even.13?}}")).to eq "" 28 | end 29 | 30 | example "with attributes" do 31 | define_presenter do 32 | def square?(width:, height:) 33 | width.to_i == height.to_i 34 | end 35 | end 36 | 37 | expect(render("{{#square? width=2 height=2}}square{{/square?}}")).to eq "square" 38 | expect(render("{{#square? width=3 height=2}}square{{/square?}}")).to eq "" 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/generators/curly/controller/controller_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/named_base' 3 | 4 | module Curly 5 | module Generators 6 | class ControllerGenerator < Rails::Generators::NamedBase 7 | source_root File.expand_path("../templates", __FILE__) 8 | 9 | argument :actions, type: :array, default: [], banner: "action action" 10 | 11 | def create_view_files 12 | base_views_path = File.join("app/views", class_path, file_name) 13 | base_presenters_path = File.join("app/presenters", class_path, file_name) 14 | 15 | empty_directory base_views_path 16 | empty_directory base_presenters_path 17 | 18 | actions.each do |action| 19 | @view_path = File.join(base_views_path, "#{action}.html.curly") 20 | @presenter_path = File.join(base_presenters_path, "#{action}_presenter.rb") 21 | @action = action 22 | @presenter_name = "#{class_name}::#{action.capitalize}Presenter" 23 | 24 | template "view.html.curly.erb", @view_path 25 | template "presenter.rb.erb", @presenter_path 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/generators/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'genspec' 2 | require 'generators/curly/install/install_generator' 3 | 4 | describe Curly::Generators::InstallGenerator do 5 | with_args %w() 6 | 7 | it "generates a Curly template for the application layout" do 8 | expect(subject).to call_action(:remove_file, "app/views/layouts/application.html.erb") 9 | expect(subject).to generate("app/views/layouts/application.html.curly") {|content| 10 | expect(content).to include "Dummy" 11 | expect(content).to include "{{csrf_meta_tags}}" 12 | expect(content).to include "{{stylesheet_links}}" 13 | expect(content).to include "{{javascript_links}}" 14 | expect(content).to include "{{yield}}" 15 | } 16 | end 17 | it "generates a Curly presenter for the form view" do 18 | expect(subject).to generate("app/presenters/layouts/application_presenter.rb") {|content| 19 | expect(content).to include "class Layouts::ApplicationPresenter < Curly::Presenter" 20 | expect(content).to include "exposes_helper :csrf_meta_tags" 21 | expect(content).to include "def stylesheet_links" 22 | expect(content).to include "def javascript_links" 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/attribute_scanner_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::AttributeScanner do 2 | it "scans attributes" do 3 | expect(scan("width=10px height=20px")).to eq({ 4 | "width" => "10px", 5 | "height" => "20px" 6 | }) 7 | end 8 | 9 | it "scans single quoted values" do 10 | expect(scan("title='hello world'")).to eq({ "title" => "hello world" }) 11 | end 12 | 13 | it "scans double quoted values" do 14 | expect(scan('title="hello world"')).to eq({ "title" => "hello world" }) 15 | end 16 | 17 | it "scans mixed quotes" do 18 | expect(scan(%[x=y q="foo's bar" v='bim " bum' t="foo ' bar"])).to eq({ 19 | "x" => "y", 20 | "q" => "foo's bar", 21 | "t" => "foo ' bar", 22 | "v" => 'bim " bum' 23 | }) 24 | end 25 | 26 | it "deals with weird whitespace" do 27 | expect(scan(" size=big ")).to eq({ "size" => "big" }) 28 | end 29 | 30 | it "scans empty attribute lists" do 31 | expect(scan(nil)).to eq({}) 32 | expect(scan("")).to eq({}) 33 | expect(scan(" ")).to eq({}) 34 | end 35 | 36 | it "fails when an invalid attribute list is passed" do 37 | expect { scan("foo") }.to raise_exception(Curly::AttributeError) 38 | expect { scan("foo=") }.to raise_exception(Curly::AttributeError) 39 | end 40 | 41 | def scan(str) 42 | described_class.scan(str) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/generators/controller_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'genspec' 2 | require 'generators/curly/controller/controller_generator' 3 | 4 | describe Curly::Generators::ControllerGenerator do 5 | with_args "animals/cows", "foo" 6 | 7 | it "generates a Curly template for each action" do 8 | expect(subject).to generate("app/views/animals/cows/foo.html.curly") {|content| 9 | expected_content = "

    Animals::Cows#foo

    \n" + 10 | "

    Find me in app/views/animals/cows/foo.html.curly

    \n" 11 | 12 | expect(content).to eq expected_content 13 | } 14 | end 15 | 16 | it "generates a Curly presenter for each action" do 17 | expect(subject).to generate("app/presenters/animals/cows/foo_presenter.rb") {|content| 18 | expected_content = (<<-RUBY).gsub(/^\s{8}/, "") 19 | class Animals::Cows::FooPresenter < Curly::Presenter 20 | # If you need to assign variables to the presenter, you can use the 21 | # `presents` method. 22 | # 23 | # presents :foo, :bar 24 | # 25 | # Any public method defined in a presenter class will be available 26 | # to the Curly template as a variable. Consider making these methods 27 | # idempotent. 28 | end 29 | RUBY 30 | 31 | expect(content).to eq expected_content 32 | } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | specs: 7 | name: Ruby ${{ matrix.ruby }} using ${{ matrix.gemfile }} 8 | runs-on: ubuntu-latest 9 | env: 10 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: 15 | - '3.2' 16 | - '3.3' 17 | - '3.4' 18 | gemfile: 19 | - rails6.1 20 | - rails7.0 21 | - rails7.1 22 | - rails7.2 23 | - rails8.0 24 | - rails_main 25 | include: [] 26 | exclude: 27 | - ruby: '3.4' 28 | gemfile: rails6.1 29 | - ruby: '3.4' 30 | gemfile: rails7.0 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | bundler-cache: true 37 | - run: bundle exec rspec 38 | 39 | specs_successful: 40 | name: Specs passing? 41 | needs: specs 42 | if: always() 43 | runs-on: ubuntu-latest 44 | steps: 45 | - run: | 46 | if ${{ needs.specs.result == 'success' }} 47 | then 48 | echo "All specs pass" 49 | else 50 | echo "Some specs failed" 51 | false 52 | fi 53 | -------------------------------------------------------------------------------- /perf/compile_profile.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'benchmark/ips' 3 | require 'stackprof' 4 | 5 | ENV["RAILS_ENV"] = "test" 6 | 7 | require_relative '../spec/dummy/config/environment' 8 | 9 | class TestPresenter < Curly::Presenter 10 | def foo 11 | end 12 | 13 | def bar 14 | end 15 | 16 | def form(&block) 17 | xcontent_tag :form, block.call 18 | end 19 | 20 | class FormPresenter < Curly::Presenter 21 | def fields 22 | %w[] 23 | end 24 | 25 | class FieldPresenter < Curly::Presenter 26 | presents :field 27 | 28 | def field_name 29 | @field 30 | end 31 | end 32 | end 33 | end 34 | 35 | # Build a huge template. 36 | TEMPLATE = <<-CURLY 37 |

    {{foo}}

    38 |

    {{bar}}

    39 | 40 | {{@form}} 41 | {{*fields}} 42 |
    43 | {{/fields}} 44 | {{*fields}} 45 |
    46 | {{/fields}} 47 | {{*fields}} 48 |
    49 | {{/fields}} 50 | {{*fields}} 51 |
    52 | {{/fields}} 53 | {{*fields}} 54 |
    55 | {{/fields}} 56 | {{*fields}} 57 |
    58 | {{/fields}} 59 | {{/form}} 60 | CURLY 61 | 62 | StackProf.run(mode: :cpu, out: "tmp/stackprof-cpu-compile.dump") do 63 | Curly::Compiler.compile(TEMPLATE * 100, TestPresenter) 64 | end 65 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/show_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= plural_table_name.capitalize %>::<%= @view_name.capitalize %>Presenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | presents :<%= singular_table_name %> 11 | 12 | def <%= singular_table_name %> 13 | @<%= singular_table_name %> 14 | end 15 | 16 | def notice_text 17 | notice 18 | end 19 | 20 | def <%= index_helper %>_link 21 | link_to 'Back', <%= index_helper %>_path 22 | end 23 | 24 | class <%= singular_table_name.capitalize %>Presenter < Curly::Presenter 25 | presents :<%= singular_table_name %> 26 | 27 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 28 | def <%= attribute.name %> 29 | @<%= singular_table_name %>.<%= attribute.name %> 30 | end 31 | <% end -%> 32 | 33 | def show_link 34 | link_to 'Show', @<%= singular_table_name %> 35 | end 36 | 37 | def edit_link 38 | link_to 'Edit', edit_<%= singular_table_name %>_path(@<%= singular_table_name %>) 39 | end 40 | 41 | def destroy_link 42 | link_to 'Destroy', @<%= singular_table_name %>, method: :delete, data: { confirm: 'Are you sure?' } 43 | end 44 | end 45 | 46 | end -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/index_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= plural_table_name.capitalize %>::<%= @view_name.capitalize %>Presenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | presents :<%= plural_table_name %> 11 | 12 | def <%= plural_table_name %> 13 | @<%= plural_table_name %> 14 | end 15 | 16 | def notice_text 17 | notice 18 | end 19 | 20 | def create_link 21 | link_to 'New <%= singular_table_name.titleize %>', new_<%= singular_table_name %>_path 22 | end 23 | 24 | class <%= singular_table_name.capitalize %>Presenter < Curly::Presenter 25 | presents :<%= singular_table_name %> 26 | 27 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 28 | def <%= attribute.name %> 29 | @<%= singular_table_name %>.<%= attribute.name %> 30 | end 31 | <% end -%> 32 | 33 | def show_link 34 | link_to 'Show', @<%= singular_table_name %> 35 | end 36 | 37 | def edit_link 38 | link_to 'Edit', edit_<%= singular_table_name %>_path(@<%= singular_table_name %>) 39 | end 40 | 41 | def destroy_link 42 | link_to 'Destroy', @<%= singular_table_name %>, method: :delete, data: { confirm: 'Are you sure?' } 43 | end 44 | end 45 | 46 | end -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/scaffold_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/resource_helpers" 2 | require "generators/curly" 3 | 4 | module Curly # :nodoc: 5 | module Generators # :nodoc: 6 | class ScaffoldGenerator < Base # :nodoc: 7 | include Rails::Generators::ResourceHelpers 8 | 9 | source_root File.expand_path("../templates", __FILE__) 10 | 11 | argument :attributes, type: :array, default: [], banner: "field:type field:type" 12 | 13 | def create_root_folder 14 | empty_directory File.join("app/views", controller_file_path) 15 | empty_directory File.join("app/presenters", controller_file_path) 16 | end 17 | 18 | def copy_view_files 19 | available_views.each do |view| 20 | # Useful in the presenters. 21 | @view_name = presenter_view(view) 22 | # Example: posts/index.html.curly 23 | view_file = "#{view}.#{format}.curly" 24 | template "#{view_file}.erb", File.join("app/views", controller_file_path, view_file) 25 | # Example: posts/index_presenter.rb 26 | presenter_file = "#{@view_name}_presenter.rb" 27 | template "#{presenter_file}.erb", File.join("app/presenters", controller_file_path, presenter_file) 28 | end 29 | end 30 | 31 | private 32 | 33 | # Hack for _form view. 34 | def presenter_view(view) 35 | view.gsub(/^_/, '') 36 | end 37 | 38 | def available_views 39 | %w(index show edit _form new) 40 | end 41 | end 42 | end 43 | end -------------------------------------------------------------------------------- /perf/compile_benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'benchmark/ips' 3 | 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | require_relative '../spec/dummy/config/environment' 7 | 8 | class TestPresenter < Curly::Presenter 9 | def foo 10 | end 11 | 12 | def bar 13 | end 14 | 15 | def form(&block) 16 | xcontent_tag :form, block.call 17 | end 18 | 19 | class FormPresenter < Curly::Presenter 20 | def fields 21 | %w[] 22 | end 23 | 24 | class FieldPresenter < Curly::Presenter 25 | presents :field 26 | 27 | def field_name 28 | @field 29 | end 30 | end 31 | end 32 | end 33 | 34 | # Build a huge template. 35 | TEMPLATE = <<-CURLY 36 |

    {{foo}}

    37 |

    {{bar}}

    38 | 39 | {{@form}} 40 | {{*fields}} 41 |
    42 | {{/fields}} 43 | {{*fields}} 44 |
    45 | {{/fields}} 46 | {{*fields}} 47 |
    48 | {{/fields}} 49 | {{*fields}} 50 |
    51 | {{/fields}} 52 | {{*fields}} 53 |
    54 | {{/fields}} 55 | {{*fields}} 56 |
    57 | {{/fields}} 58 | {{/form}} 59 | CURLY 60 | 61 | Benchmark.ips do |x| 62 | x.report "compiling a huge template" do 63 | Curly::Compiler.compile(TEMPLATE * 100, TestPresenter) 64 | end 65 | 66 | x.report "compiling a normal template" do 67 | Curly::Compiler.compile(TEMPLATE, TestPresenter) 68 | end 69 | 70 | x.compare! 71 | end 72 | -------------------------------------------------------------------------------- /lib/curly/attribute_scanner.rb: -------------------------------------------------------------------------------- 1 | require 'curly/error' 2 | 3 | module Curly 4 | AttributeError = Class.new(Curly::Error) 5 | 6 | class AttributeScanner 7 | def self.scan(string) 8 | return {} if string.nil? 9 | new(string).scan 10 | end 11 | 12 | def initialize(string) 13 | @scanner = StringScanner.new(string) 14 | end 15 | 16 | def scan 17 | attributes = scan_attributes 18 | Hash[attributes] 19 | end 20 | 21 | private 22 | 23 | def scan_attributes 24 | attributes = [] 25 | 26 | while attribute = scan_attribute 27 | attributes << attribute 28 | end 29 | 30 | attributes 31 | end 32 | 33 | def scan_attribute 34 | skip_whitespace 35 | 36 | return if @scanner.eos? 37 | 38 | name = scan_name or raise AttributeError 39 | value = scan_value or raise AttributeError 40 | 41 | [name, value] 42 | end 43 | 44 | def scan_name 45 | name = @scanner.scan(/\w+=/) 46 | name && name[0..-2] 47 | end 48 | 49 | def scan_value 50 | scan_unquoted_value || scan_single_quoted_value || scan_double_quoted_value 51 | end 52 | 53 | def scan_unquoted_value 54 | @scanner.scan(/\w+/) 55 | end 56 | 57 | def scan_single_quoted_value 58 | value = @scanner.scan(/'[^']*'/) 59 | value && value[1..-2] 60 | end 61 | 62 | def scan_double_quoted_value 63 | value = @scanner.scan(/"[^"]*"/) 64 | value && value[1..-2] 65 | end 66 | 67 | def skip_whitespace 68 | @scanner.skip(/\s*/) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | 3 | require 'dummy/config/environment' 4 | require 'rspec/rails' 5 | 6 | RSpec.configure do |config| 7 | config.infer_spec_type_from_file_location! 8 | end 9 | 10 | module CompilationSupport 11 | def define_presenter(name = "ShowPresenter", &block) 12 | presenter_class = Class.new(Curly::Presenter, &block) 13 | stub_const(name, presenter_class) 14 | presenter_class 15 | end 16 | 17 | def render(source, options = {}, &block) 18 | presenter = options.fetch(:presenter) do 19 | define_presenter("ShowPresenter") unless defined?(ShowPresenter) 20 | "ShowPresenter" 21 | end.constantize 22 | 23 | virtual_path = options.fetch(:virtual_path) do 24 | presenter.name.underscore.gsub(/_presenter\z/, "") 25 | end 26 | 27 | identifier = options.fetch(:identifier) do 28 | defined?(Rails.root) ? "#{Rails.root}/#{virtual_path}.html.curly" : virtual_path 29 | end 30 | 31 | locals = options.fetch(:locals, {}) 32 | details = { virtual_path: virtual_path, locals: locals.keys } 33 | details.merge! options.fetch(:details, {}) 34 | 35 | handler = Curly::TemplateHandler 36 | template = ActionView::Template.new(source, identifier, handler, **details) 37 | lookup_context = ActionView::LookupContext.new([]) 38 | view = ActionView::Base.with_empty_template_cache.new(lookup_context, {}, nil) 39 | 40 | begin 41 | template.render(view, locals, &block) 42 | rescue ActionView::Template::Error => e 43 | raise e.respond_to?(:cause) ? e.cause : e.original_exception 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Print deprecation notices to the stderr. 30 | config.active_support.deprecation = :stderr 31 | 32 | # Raises error for missing translations 33 | # config.action_view.raise_on_missing_translations = true 34 | 35 | config.secret_key_base = "yolo" 36 | end 37 | -------------------------------------------------------------------------------- /lib/curly.rb: -------------------------------------------------------------------------------- 1 | # Curly is a simple view system. Each view consists of two parts, a 2 | # template and a presenter. The template is a simple string that can contain 3 | # components in the format `{{refname}}`, e.g. 4 | # 5 | # Hello {{recipient}}, 6 | # you owe us ${{amount}}. 7 | # 8 | # The components will be converted into messages that are sent to the 9 | # presenter, which is any Ruby object. Only public methods can be referenced. 10 | # To continue the earlier example, here's the matching presenter: 11 | # 12 | # class BankPresenter 13 | # def initialize(recipient, amount) 14 | # @recipient, @amount = recipient, amount 15 | # end 16 | # 17 | # def recipient 18 | # @recipient.full_name 19 | # end 20 | # 21 | # def amount 22 | # "%.2f" % @amount 23 | # end 24 | # end 25 | # 26 | # See Curly::Presenter for more information on presenters. 27 | module Curly 28 | 29 | # Compiles a Curly template to Ruby code. 30 | # 31 | # template - The template String that should be compiled. 32 | # 33 | # Returns a String containing the Ruby code. 34 | def self.compile(template, presenter_class) 35 | Compiler.compile(template, presenter_class) 36 | end 37 | 38 | # Whether the Curly template is valid. This includes whether all 39 | # components are available on the presenter class. 40 | # 41 | # template - The template String that should be validated. 42 | # presenter_class - The presenter Class. 43 | # 44 | # Returns true if the template is valid, false otherwise. 45 | def self.valid?(template, presenter_class) 46 | Compiler.valid?(template, presenter_class) 47 | end 48 | end 49 | 50 | require 'curly/compiler' 51 | require 'curly/presenter' 52 | require 'curly/template_handler' 53 | require 'curly/railtie' if defined?(Rails) 54 | require 'curly/version' 55 | -------------------------------------------------------------------------------- /spec/collection_blocks_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Collection block components" do 2 | include CompilationSupport 3 | 4 | before do 5 | define_presenter "ItemPresenter" do 6 | presents :item 7 | 8 | def name 9 | @item 10 | end 11 | end 12 | end 13 | 14 | example "with neither identifier nor attributes" do 15 | define_presenter do 16 | def items 17 | ["one", "two", "three"] 18 | end 19 | end 20 | 21 | expect(render("{{*items}}<{{name}}>{{/items}}")).to eq "" 22 | end 23 | 24 | example "with an identifier" do 25 | define_presenter do 26 | def items(filter = nil) 27 | if filter == "even" 28 | ["two"] 29 | elsif filter == "odd" 30 | ["one", "three"] 31 | else 32 | ["one", "two", "three"] 33 | end 34 | end 35 | end 36 | 37 | expect(render("{{*items.even}}<{{name}}>{{/items.even}}")).to eq "" 38 | expect(render("{{*items.odd}}<{{name}}>{{/items.odd}}")).to eq "" 39 | expect(render("{{*items}}<{{name}}>{{/items}}")).to eq "" 40 | end 41 | 42 | example "with attributes" do 43 | define_presenter do 44 | def items(length: "1") 45 | ["x"] * length.to_i 46 | end 47 | end 48 | 49 | expect(render("{{*items length=3}}<{{name}}>{{/items}}")).to eq "" 50 | expect(render("{{*items}}<{{name}}>{{/items}}")).to eq "" 51 | end 52 | 53 | example "with nested collection blocks" do 54 | define_presenter do 55 | def items 56 | [{ parts: [1, 2] }, { parts: [3, 4] }] 57 | end 58 | end 59 | 60 | define_presenter "ItemPresenter" do 61 | presents :item 62 | 63 | def parts 64 | @item[:parts] 65 | end 66 | end 67 | 68 | define_presenter "PartPresenter" do 69 | presents :part 70 | 71 | def number 72 | @part 73 | end 74 | end 75 | 76 | expect(render("{{*items}}<{{*parts}}[{{number}}]{{/parts}}>{{/items}}")).to eq "<[1][2]><[3][4]>" 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | In order to keep the Curly code base nice and tidy, please observe these best practises when making contributions: 2 | 3 | - Add tests for all your code. Make sure the tests are clear and fail with proper error messages. It's a good idea to let your test fail in order to review whether the message makes sense; then make the test pass. 4 | - Document any unclear things in the code. Even better, don't make the code do unclear things. 5 | - Use the coding style already present in the code base. 6 | - Make your commit messages precise and to the point. Add a short summary (50 chars max) followed by a blank line and then a longer description, if necessary, e.g. 7 | 8 | > Make invalid references raise an exception 9 | > 10 | > In order to avoid nasty errors when doing stuff, make the Curly compiler 11 | > fail early when an invalid reference is encountered. 12 | 13 | Before making a contribution, you should make sure to understand what Curly is and isn't: 14 | 15 | - The template language will never be super advanced: one of the primary use cases for Curly is to allow end users to mess around with Curly templates and have them safely compiled and rendered on a server. As such, the template language will always be as simple as possible. 16 | - The template language is declarative, and is going to stay that way. 17 | 18 | ### Releasing a new version 19 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch. 20 | In short, follow these steps: 21 | 1. Update `version.rb`, 22 | 2. update version in all `Gemfile.lock` files, 23 | 3. merge this change into `main`, and 24 | 4. look at [the action](https://github.com/zendesk/curly/actions/workflows/publish.yml) for output. 25 | 26 | To create a pre-release from a non-main branch: 27 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`, 28 | 2. push this change to your branch, 29 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/curly/actions/workflows/publish.yml), 30 | 4. click the “Run workflow” button, 31 | 5. pick your branch from a dropdown. 32 | -------------------------------------------------------------------------------- /lib/generators/curly/scaffold/templates/form_presenter.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= plural_table_name.capitalize %>::<%= @view_name.capitalize %>Presenter < Curly::Presenter 2 | # If you need to assign variables to the presenter, you can use the 3 | # `presents` method. 4 | # 5 | # presents :foo, :bar 6 | # 7 | # Any public method defined in a presenter class will be available 8 | # to the Curly template as a variable. Consider making these methods 9 | # idempotent. 10 | presents :<%= singular_table_name %> 11 | 12 | def <%= singular_table_name %>_errors 13 | @<%= singular_table_name %>.errors 14 | end 15 | 16 | def <%= singular_table_name %> 17 | @<%= singular_table_name %> 18 | end 19 | 20 | def <%= singular_table_name %>_form(&block) 21 | form_for(@<%= singular_table_name %>, &block) 22 | end 23 | 24 | class <%= singular_table_name.capitalize %>FormPresenter < Curly::Presenter 25 | presents :<%= singular_table_name %>_form, :<%= singular_table_name %> 26 | 27 | def <%= singular_table_name %>_errors(&block) 28 | block.call(@<%= singular_table_name %>.errors) 29 | end 30 | 31 | def label(field_name) 32 | @<%= singular_table_name %>_form.label field_name.to_sym 33 | end 34 | 35 | def field(field_type: nil, field_name: nil) 36 | @<%= singular_table_name %>_form.send field_type.to_sym, field_name.to_sym 37 | end 38 | 39 | def submit 40 | @<%= singular_table_name %>_form.submit 41 | end 42 | 43 | class <%= singular_table_name.capitalize %>ErrorsPresenter < Curly::Presenter 44 | presents :<%= singular_table_name %>_errors 45 | 46 | def any? 47 | @<%= singular_table_name %>_errors.any? 48 | end 49 | 50 | def header 51 | "#{pluralize(@<%= singular_table_name %>_errors.count, "error")} prohibited this <%= singular_table_name %> from being saved:" 52 | end 53 | 54 | def error_messages(&block) 55 | @<%= singular_table_name %>_errors.full_messages 56 | end 57 | 58 | class ErrorMessagePresenter < Curly::Presenter 59 | presents :error_message 60 | def message 61 | @error_message 62 | end 63 | end 64 | end 65 | end 66 | end -------------------------------------------------------------------------------- /spec/parser_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Parser do 2 | it "parses component tokens" do 3 | tokens = [ 4 | [:component, "a", nil, {}], 5 | ] 6 | 7 | expect(parse(tokens)).to eq [ 8 | component("a") 9 | ] 10 | end 11 | 12 | it "parses conditional blocks" do 13 | tokens = [ 14 | [:conditional_block_start, "a?", nil, {}], 15 | [:component, "hello", nil, {}], 16 | [:block_end, "a?", nil], 17 | ] 18 | 19 | expect(parse(tokens)).to eq [ 20 | conditional_block(component("a?"), [component("hello")]) 21 | ] 22 | end 23 | 24 | it "parses inverse conditional blocks" do 25 | tokens = [ 26 | [:inverse_conditional_block_start, "a?", nil, {}], 27 | [:component, "hello", nil, {}], 28 | [:block_end, "a?", nil], 29 | ] 30 | 31 | expect(parse(tokens)).to eq [ 32 | inverse_conditional_block(component("a?"), [component("hello")]) 33 | ] 34 | end 35 | 36 | it "parses collection blocks" do 37 | tokens = [ 38 | [:collection_block_start, "mice", nil, {}], 39 | [:component, "hello", nil, {}], 40 | [:block_end, "mice", nil], 41 | ] 42 | 43 | expect(parse(tokens)).to eq [ 44 | collection_block(component("mice"), [component("hello")]) 45 | ] 46 | end 47 | 48 | it "fails if a block is not closed" do 49 | tokens = [ 50 | [:collection_block_start, "mice", nil, {}], 51 | ] 52 | 53 | expect { parse(tokens) }.to raise_exception(Curly::IncompleteBlockError) 54 | end 55 | 56 | it "fails if a block is closed with the wrong component" do 57 | tokens = [ 58 | [:collection_block_start, "mice", nil, {}], 59 | [:block_end, "men", nil, {}], 60 | ] 61 | 62 | expect { parse(tokens) }.to raise_exception(Curly::IncorrectEndingError) 63 | end 64 | 65 | it "fails if there is a closing component too many" do 66 | tokens = [ 67 | [:block_end, "world", nil, {}], 68 | ] 69 | 70 | expect { parse(tokens) }.to raise_exception(Curly::IncorrectEndingError) 71 | end 72 | 73 | def parse(tokens) 74 | described_class.parse(tokens) 75 | end 76 | 77 | def component(*args) 78 | Curly::Parser::Component.new(*args) 79 | end 80 | 81 | def conditional_block(*args) 82 | Curly::Parser::Block.new(:conditional, *args) 83 | end 84 | 85 | def inverse_conditional_block(*args) 86 | Curly::Parser::Block.new(:inverse_conditional, *args) 87 | end 88 | 89 | def collection_block(*args) 90 | Curly::Parser::Block.new(:collection, *args) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/curly/template_handler.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'action_view' 3 | require 'curly' 4 | require 'curly/presenter_not_found' 5 | 6 | class Curly::TemplateHandler 7 | class << self 8 | 9 | # Handles a Curly template, compiling it to Ruby code. The code will be 10 | # evaluated in the context of an ActionView::Base instance, having access 11 | # to a number of variables. 12 | # 13 | # template - The ActionView::Template template that should be compiled. 14 | # 15 | # Returns a String containing the Ruby code representing the template. 16 | def call(template, source) 17 | instrument(template) do 18 | compile(template, source) 19 | end 20 | end 21 | 22 | def cache_if_key_is_not_nil(context, presenter) 23 | if key = presenter.cache_key 24 | if presenter.class.respond_to?(:cache_key) 25 | presenter_key = presenter.class.cache_key 26 | else 27 | presenter_key = nil 28 | end 29 | 30 | cache_options = presenter.cache_options || {} 31 | cache_options[:expires_in] ||= presenter.cache_duration 32 | 33 | context.cache([key, presenter_key].compact, cache_options) do 34 | yield 35 | end 36 | else 37 | yield 38 | end 39 | end 40 | 41 | private 42 | 43 | def compile_for_actionview5(template) 44 | compile(template, template.source) 45 | end 46 | 47 | def compile(template, source) 48 | # Template source is empty, so there's no need to initialize a presenter. 49 | return %("") if source.empty? 50 | 51 | path = template.virtual_path 52 | presenter_class = Curly::Presenter.presenter_for_path(path) 53 | 54 | raise Curly::PresenterNotFound.new(path) if presenter_class.nil? 55 | 56 | compiled_source = Curly.compile(source, presenter_class) 57 | 58 | <<-RUBY 59 | if local_assigns.empty? 60 | options = assigns 61 | else 62 | options = local_assigns 63 | end 64 | 65 | presenter = ::#{presenter_class}.new(self, options) 66 | presenter.setup! 67 | 68 | @output_buffer = output_buffer || ActiveSupport::SafeBuffer.new 69 | 70 | Curly::TemplateHandler.cache_if_key_is_not_nil(self, presenter) do 71 | result = #{compiled_source} 72 | safe_concat(result) 73 | end 74 | 75 | @output_buffer 76 | RUBY 77 | end 78 | 79 | def instrument(template, &block) 80 | payload = { path: template.virtual_path } 81 | ActiveSupport::Notifications.instrument("compile.curly", payload, &block) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/generators/scaffold_curly_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'genspec' 2 | require 'generators/curly/scaffold/scaffold_generator' 3 | 4 | describe Curly::Generators::ScaffoldGenerator do 5 | with_args %w(article title body published:boolean) 6 | 7 | it "generates an Curly template for the index view" do 8 | expect(subject).to generate("app/views/articles/index.html.curly") {|content| 9 | expect(content).to include "Body" 10 | expect(content).to include "Title" 11 | expect(content).to include "Published" 12 | expect(content).to include "{{*articles}}" 13 | # Consistantly not have spaces between curlys. 14 | expect(content).to include "{{show_link}}" 15 | expect(content).to include "{{edit_link}}" 16 | expect(content).to include "{{notice_text}}" 17 | expect(content).to include "{{destroy_link}}" 18 | expect(content).to include "{{title}}" 19 | expect(content).to include "{{body}}" 20 | expect(content).to include "{{published}}" 21 | } 22 | end 23 | it "generates an Curly template for the show view" do 24 | expect(subject).to generate("app/views/articles/show.html.curly") {|content| 25 | expect(content).to include "{{*article}}" 26 | expect(content).to include "Title:" 27 | expect(content).to include "{{title}}" 28 | expect(content).to include "Body:" 29 | expect(content).to include "{{body}}" 30 | expect(content).to include "Published:" 31 | expect(content).to include "{{published}}" 32 | expect(content).to include "{{articles_link}}" 33 | } 34 | end 35 | it "generates an Curly template for the new view" do 36 | expect(subject).to generate("app/views/articles/new.html.curly") {|content| 37 | expect(content).to include "

    New Article

    " 38 | expect(content).to include "{{article_form}}" 39 | } 40 | end 41 | it "generates an Curly template for the edit view" do 42 | expect(subject).to generate("app/views/articles/edit.html.curly") {|content| 43 | expect(content).to include "

    Editing Article

    " 44 | expect(content).to include "{{article_form}}" 45 | } 46 | end 47 | it "generates an Curly template for the form view" do 48 | expect(subject).to generate("app/views/articles/_form.html.curly") {|content| 49 | expect(content).to include "{{@article_form}}" 50 | expect(content).to include "{{#article_errors:any?}}" 51 | expect(content).to include "{{label.title}}" 52 | expect(content).to include "{{label.body}}" 53 | expect(content).to include "{{label.published}}" 54 | expect(content).to include "{{submit}}" 55 | } 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/compiler/context_blocks_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Compiler do 2 | include CompilationSupport 3 | 4 | it "compiles context blocks" do 5 | define_presenter do 6 | def form(&block) 7 | "
    ".html_safe + block.call("yo") + "
    ".html_safe 8 | end 9 | end 10 | 11 | define_presenter "FormPresenter" do 12 | presents :form 13 | 14 | def text_field(&block) 15 | block.call(@form) 16 | end 17 | end 18 | 19 | define_presenter "TextFieldPresenter" do 20 | presents :text_field 21 | 22 | def field 23 | %().html_safe 24 | end 25 | 26 | def value? 27 | true 28 | end 29 | end 30 | 31 | expect(render('{{@form}}{{@text_field}}{{field}}{{/text_field}}{{/form}}')).to eq '
    ' 32 | end 33 | 34 | it "compiles using the right presenter" do 35 | define_presenter "Layouts::SomePresenter" do 36 | 37 | def contents(&block) 38 | block.call("hello, world") 39 | end 40 | end 41 | 42 | define_presenter "Layouts::SomePresenter::ContentsPresenter" do 43 | presents :contents 44 | 45 | def contents 46 | @contents 47 | end 48 | end 49 | 50 | expect(render("foo: {{@contents}}{{contents}}{{/contents}}", presenter: "Layouts::SomePresenter")).to eq 'foo: hello, world' 51 | end 52 | 53 | it "fails if the component is not a context block" do 54 | define_presenter do 55 | def form 56 | end 57 | end 58 | 59 | expect { 60 | render('{{@form}}{{/form}}') 61 | }.to raise_exception(Curly::Error) 62 | end 63 | 64 | it "fails if the component doesn't match a presenter class" do 65 | define_presenter do 66 | def dust(&block) 67 | end 68 | end 69 | 70 | expect { 71 | render('{{@dust}}{{/dust}}') 72 | }.to raise_exception(Curly::Error) 73 | end 74 | 75 | it "fails if the component is not a context block" do 76 | expect { render('{{@invalid}}yo{{/invalid}}') }.to raise_exception(Curly::Error) 77 | end 78 | 79 | it "compiles shorthand context components" do 80 | define_presenter do 81 | def tree(&block) 82 | yield 83 | end 84 | end 85 | 86 | define_presenter "TreePresenter" do 87 | def branch(&block) 88 | yield 89 | end 90 | end 91 | 92 | define_presenter "BranchPresenter" do 93 | def leaf 94 | "leaf" 95 | end 96 | end 97 | 98 | expect(render('{{tree:branch:leaf}}')).to eq "leaf" 99 | end 100 | 101 | it "requires shorthand blocks to be closed with the same set of namespaces" do 102 | expect { 103 | render('{{#tree:branch}}{{/branch}}{{/tree}}') 104 | }.to raise_exception(Curly::IncorrectEndingError) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/generators/scaffold_presenter_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'genspec' 2 | require 'generators/curly/scaffold/scaffold_generator' 3 | 4 | describe Curly::Generators::ScaffoldGenerator do 5 | with_args %w(article title body published:boolean) 6 | 7 | it "generates a Curly presenter for the index view" do 8 | expect(subject).to generate("app/presenters/articles/index_presenter.rb") {|content| 9 | expect(content).to include "class Articles::IndexPresenter < Curly::Presenter" 10 | expect(content).to include "presents :articles" 11 | expect(content).to include "def articles" 12 | expect(content).to include "def notice_text" 13 | expect(content).to include "def create_link" 14 | expect(content).to include "class ArticlePresenter < Curly::Presenter" 15 | expect(content).to include "def title" 16 | expect(content).to include "def body" 17 | expect(content).to include "def published" 18 | } 19 | end 20 | it "generates a Curly presenter for the show view" do 21 | expect(subject).to generate("app/presenters/articles/show_presenter.rb") {|content| 22 | expect(content).to include "class Articles::ShowPresenter < Curly::Presenter" 23 | expect(content).to include "presents :article" 24 | expect(content).to include "def article" 25 | expect(content).to include "def notice_text" 26 | expect(content).to include "def articles_link" 27 | expect(content).to include "class ArticlePresenter < Curly::Presenter" 28 | expect(content).to include "def title" 29 | expect(content).to include "def body" 30 | expect(content).to include "def published" 31 | } 32 | end 33 | it "generates a Curly presenter for the new view" do 34 | expect(subject).to generate("app/presenters/articles/new_presenter.rb") {|content| 35 | expect(content).to include "class Articles::NewPresenter < Curly::Presenter" 36 | expect(content).to include "presents :article" 37 | expect(content).to include "def article_form" 38 | expect(content).to include "render 'form', article: @article" 39 | expect(content).to include "def articles_link" 40 | } 41 | end 42 | it "generates a Curly presenter for the edit view" do 43 | expect(subject).to generate("app/presenters/articles/edit_presenter.rb") {|content| 44 | expect(content).to include "class Articles::EditPresenter < Curly::Presenter" 45 | expect(content).to include "presents :article" 46 | expect(content).to include "def article" 47 | expect(content).to include "def article_form" 48 | expect(content).to include "render 'form', article: @article" 49 | expect(content).to include "def articles_link" 50 | } 51 | end 52 | it "generates a Curly presenter for the form view" do 53 | expect(subject).to generate("app/presenters/articles/form_presenter.rb") {|content| 54 | expect(content).to include "class Articles::FormPresenter < Curly::Presenter" 55 | expect(content).to include "presents :article" 56 | expect(content).to include "def article_errors" 57 | expect(content).to include "def article_form(&block)" 58 | expect(content).to include "def submit" 59 | expect(content).to include "class ArticleFormPresenter < Curly::Presenter" 60 | expect(content).to include "class ArticleErrorsPresenter < Curly::Presenter" 61 | expect(content).to include "class ErrorMessagePresenter < Curly::Presenter" 62 | } 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/scanner_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Scanner, ".scan" do 2 | it "returns the tokens in the source" do 3 | expect(scan("foo {{bar}} baz")).to eq [ 4 | [:text, "foo "], 5 | [:component, "bar", nil, {}, []], 6 | [:text, " baz"] 7 | ] 8 | end 9 | 10 | it "scans components with identifiers" do 11 | expect(scan("{{foo.bar}}")).to eq [ 12 | [:component, "foo", "bar", {}, []] 13 | ] 14 | end 15 | 16 | it "scans comments in the source" do 17 | expect(scan("foo {{!bar}} baz")).to eq [ 18 | [:text, "foo "], 19 | [:comment, "bar"], 20 | [:text, " baz"] 21 | ] 22 | end 23 | 24 | it "allows newlines in comments" do 25 | expect(scan("{{!\nfoo\n}}")).to eq [ 26 | [:comment, "\nfoo\n"] 27 | ] 28 | end 29 | 30 | it "scans to the end of the source" do 31 | expect(scan("foo\n")).to eq [ 32 | [:text, "foo\n"] 33 | ] 34 | end 35 | 36 | it "allows escaping Curly quotes" do 37 | expect(scan('foo {{{ bar')).to eq [ 38 | [:text, "foo "], 39 | [:text, "{{"], 40 | [:text, " bar"] 41 | ] 42 | 43 | expect(scan('foo }} bar')).to eq [ 44 | [:text, "foo }} bar"] 45 | ] 46 | 47 | expect(scan('foo {{{ lala! }} bar')).to eq [ 48 | [:text, "foo "], 49 | [:text, "{{"], 50 | [:text, " lala! }} bar"] 51 | ] 52 | end 53 | 54 | it "scans context block tags" do 55 | expect(scan('{{@search_form}}{{query_field}}{{/search_form}}')).to eq [ 56 | [:context_block_start, "search_form", nil, {}, []], 57 | [:component, "query_field", nil, {}, []], 58 | [:block_end, "search_form", nil, {}, []] 59 | ] 60 | end 61 | 62 | it "scans conditional block tags" do 63 | expect(scan('foo {{#bar?}} hello {{/bar?}}')).to eq [ 64 | [:text, "foo "], 65 | [:conditional_block_start, "bar?", nil, {}, []], 66 | [:text, " hello "], 67 | [:block_end, "bar?", nil, {}, []] 68 | ] 69 | end 70 | 71 | it "scans conditional block tags with parameters and attributes" do 72 | expect(scan('{{#active.test? name="test"}}yo{{/active.test?}}')).to eq [ 73 | [:conditional_block_start, "active?", "test", { "name" => "test" }, []], 74 | [:text, "yo"], 75 | [:block_end, "active?", "test", {}, []] 76 | ] 77 | end 78 | 79 | it "scans inverse block tags" do 80 | expect(scan('foo {{^bar?}} hello {{/bar?}}')).to eq [ 81 | [:text, "foo "], 82 | [:inverse_conditional_block_start, "bar?", nil, {}, []], 83 | [:text, " hello "], 84 | [:block_end, "bar?", nil, {}, []] 85 | ] 86 | end 87 | 88 | it "scans collection block tags" do 89 | expect(scan('foo {{*bar}} hello {{/bar}}')).to eq [ 90 | [:text, "foo "], 91 | [:collection_block_start, "bar", nil, {}, []], 92 | [:text, " hello "], 93 | [:block_end, "bar", nil, {}, []] 94 | ] 95 | end 96 | 97 | it "treats quotes as text" do 98 | expect(scan('"')).to eq [ 99 | [:text, '"'] 100 | ] 101 | end 102 | 103 | it "treats Ruby interpolation as text" do 104 | expect(scan('#{foo}')).to eq [ 105 | [:text, '#{foo}'] 106 | ] 107 | end 108 | 109 | it "raises Curly::SyntaxError on unclosed components" do 110 | ["{{", "{{yolo"].each do |template| 111 | expect { scan(template) }.to raise_error(Curly::SyntaxError) 112 | end 113 | end 114 | 115 | it "raises Curly::SyntaxError on unclosed comments" do 116 | ["{{!", "{{! foo bar"].each do |template| 117 | expect { scan(template) }.to raise_error(Curly::SyntaxError) 118 | end 119 | end 120 | 121 | def scan(source) 122 | Curly::Scanner.scan(source) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/curly/component_compiler.rb: -------------------------------------------------------------------------------- 1 | module Curly 2 | class ComponentCompiler 3 | attr_reader :presenter_class, :component, :type 4 | 5 | def self.compile(presenter_class, component, type: nil) 6 | new(presenter_class, component, type: type).compile 7 | end 8 | 9 | def initialize(presenter_class, component, type: nil) 10 | @presenter_class, @component, @type = presenter_class, component, type 11 | end 12 | 13 | def compile 14 | unless presenter_class.component_available?(method) 15 | raise Curly::InvalidComponent.new(method) 16 | end 17 | 18 | validate_block_argument! 19 | validate_attributes! 20 | 21 | code = "presenter.#{method}(" 22 | 23 | append_positional_argument(code) 24 | append_keyword_arguments(code) 25 | 26 | code << ")" 27 | end 28 | 29 | private 30 | 31 | def method 32 | component.name 33 | end 34 | 35 | def argument 36 | component.identifier 37 | end 38 | 39 | def attributes 40 | component.attributes 41 | end 42 | 43 | def append_positional_argument(code) 44 | if required_identifier? 45 | if argument.nil? 46 | raise Curly::Error, "`#{method}` requires an identifier" 47 | end 48 | 49 | code << argument.inspect 50 | elsif optional_identifier? 51 | code << argument.inspect unless argument.nil? 52 | elsif invalid_signature? 53 | raise Curly::Error, "`#{method}` is not a valid component method" 54 | elsif !argument.nil? 55 | raise Curly::Error, "`#{method}` does not take an identifier" 56 | end 57 | end 58 | 59 | def append_keyword_arguments(code) 60 | unless keyword_argument_string.empty? 61 | code << ", " unless argument.nil? 62 | code << keyword_argument_string 63 | end 64 | end 65 | 66 | def invalid_signature? 67 | param_types.count { |type| [:req, :opt].include?(type) } > 1 68 | end 69 | 70 | def required_identifier? 71 | param_types.include?(:req) 72 | end 73 | 74 | def optional_identifier? 75 | param_types.include?(:opt) 76 | end 77 | 78 | def keyword_argument_string 79 | @keyword_argument_string ||= attributes.map {|name, value| 80 | "#{name}: #{value.inspect}" 81 | }.join(", ") 82 | end 83 | 84 | def validate_block_argument! 85 | if type == :context && !param_types.include?(:block) 86 | raise Curly::Error, "`#{method}` cannot be used as a context block" 87 | end 88 | end 89 | 90 | def validate_attributes! 91 | attributes_collected? || attributes.keys.each do |key| 92 | unless attribute_names.include?(key) 93 | raise Curly::Error, "`#{method}` does not allow attribute `#{key}`" 94 | end 95 | end 96 | 97 | required_attribute_names.each do |key| 98 | unless attributes.key?(key) 99 | raise Curly::Error, "`#{method}` is missing the required attribute `#{key}`" 100 | end 101 | end 102 | end 103 | 104 | def params 105 | @params ||= presenter_class.instance_method(method).parameters 106 | end 107 | 108 | def param_types 109 | params.map(&:first) 110 | end 111 | 112 | def attribute_names 113 | @attribute_names ||= params. 114 | select {|type, name| [:key, :keyreq].include?(type) }. 115 | map {|type, name| name.to_s } 116 | end 117 | 118 | def attributes_collected? 119 | param_types.include?(:keyrest) 120 | end 121 | 122 | def required_attribute_names 123 | @required_attribute_names ||= params. 124 | select {|type, name| type == :keyreq }. 125 | map {|type, name| name.to_s } 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/component_compiler_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::ComponentCompiler do 2 | describe ".compile" do 3 | let(:presenter_class) do 4 | Class.new do 5 | def title 6 | "Welcome!" 7 | end 8 | 9 | def i18n(key, fallback: nil) 10 | case key 11 | when "home.welcome" then "Welcome to our lovely place!" 12 | else fallback 13 | end 14 | end 15 | 16 | def collected(**options) 17 | options.to_a.map { |(k, v)| "#{k}: #{v}" }.join("\n") 18 | end 19 | 20 | def summary(length = "long") 21 | case length 22 | when "long" then "This is a long summary" 23 | when "short" then "This is a short summary" 24 | end 25 | end 26 | 27 | def invalid(x, y) 28 | end 29 | 30 | def widget(size:, color: nil) 31 | s = "Widget (#{size})" 32 | s << " - #{color}" if color 33 | s 34 | end 35 | 36 | def form(&block) 37 | "some form" 38 | end 39 | 40 | def self.component_available?(name) 41 | true 42 | end 43 | end 44 | end 45 | 46 | it "compiles components with identifiers" do 47 | expect(evaluate("i18n.home.welcome")).to eq "Welcome to our lovely place!" 48 | end 49 | 50 | it "compiles components with optional identifiers" do 51 | expect(evaluate("summary")).to eq "This is a long summary" 52 | expect(evaluate("summary.short")).to eq "This is a short summary" 53 | end 54 | 55 | it "compiles components with attributes" do 56 | expect(evaluate("widget size=100px")).to eq "Widget (100px)" 57 | end 58 | 59 | it "compiles components with collected attributes" do 60 | expect(evaluate("collected class=test for=you")).to eq "class: test\nfor: you" 61 | end 62 | 63 | it "compiles components with optional attributes" do 64 | expect(evaluate("widget color=blue size=50px")).to eq "Widget (50px) - blue" 65 | end 66 | 67 | it "compiles context block components" do 68 | expect(evaluate("form", type: :context)).to eq "some form" 69 | end 70 | 71 | it "allows both identifier and attributes" do 72 | expect(evaluate("i18n.hello fallback=yolo")).to eq "yolo" 73 | end 74 | 75 | it "fails when an invalid attribute is used" do 76 | expect { evaluate("i18n.foo extreme=true") }.to raise_exception(Curly::Error) 77 | end 78 | 79 | it "fails when a component is missing a required identifier" do 80 | expect { evaluate("i18n") }.to raise_exception(Curly::Error) 81 | end 82 | 83 | it "fails when a component is missing a required attribute" do 84 | expect { evaluate("widget") }.to raise_exception(Curly::Error) 85 | end 86 | 87 | it "fails when an identifier is specified for a component that doesn't support one" do 88 | expect { evaluate("title.rugby") }.to raise_exception(Curly::Error) 89 | end 90 | 91 | it "fails when the method takes more than one argument" do 92 | expect { evaluate("invalid") }.to raise_exception(Curly::Error) 93 | end 94 | 95 | it "fails when a context block component is used with a method that doesn't take a block" do 96 | expect { evaluate("title", type: :context) }.to raise_exception(Curly::Error) 97 | end 98 | end 99 | 100 | def evaluate(text, type: nil, &block) 101 | name, identifier, attributes = Curly::ComponentScanner.scan(text) 102 | component = Curly::Parser::Component.new(name, identifier, attributes) 103 | code = Curly::ComponentCompiler.compile(presenter_class, component, type: type) 104 | presenter = presenter_class.new 105 | context = double("context", presenter: presenter) 106 | 107 | context.instance_eval(<<-RUBY) 108 | def self.render 109 | #{code} 110 | end 111 | RUBY 112 | 113 | context.render(&block) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/curly/parser.rb: -------------------------------------------------------------------------------- 1 | require 'curly/incomplete_block_error' 2 | require 'curly/incorrect_ending_error' 3 | 4 | class Curly::Parser 5 | class Component 6 | attr_reader :name, :identifier, :attributes, :contexts 7 | 8 | def initialize(name, identifier = nil, attributes = {}, contexts = []) 9 | @name, @identifier, @attributes, @contexts = name, identifier, attributes, contexts 10 | end 11 | 12 | def to_s 13 | contexts.map {|c| c + ":" }.join << [name, identifier].compact.join(".") 14 | end 15 | 16 | def ==(other) 17 | other.name == name && 18 | other.identifier == identifier && 19 | other.attributes == attributes && 20 | other.contexts == contexts 21 | end 22 | 23 | def type 24 | :component 25 | end 26 | end 27 | 28 | class Text 29 | attr_reader :value 30 | 31 | def initialize(value) 32 | @value = value 33 | end 34 | 35 | def type 36 | :text 37 | end 38 | end 39 | 40 | class Comment 41 | attr_reader :value 42 | 43 | def initialize(value) 44 | @value = value 45 | end 46 | 47 | def type 48 | :comment 49 | end 50 | end 51 | 52 | class Root 53 | attr_reader :nodes 54 | 55 | def initialize 56 | @nodes = [] 57 | end 58 | 59 | def <<(node) 60 | @nodes << node 61 | end 62 | 63 | def to_s 64 | "" 65 | end 66 | 67 | def closed_by?(component) 68 | false 69 | end 70 | end 71 | 72 | class Block 73 | attr_reader :type, :component, :nodes 74 | 75 | def initialize(type, component, nodes = []) 76 | @type, @component, @nodes = type, component, nodes 77 | end 78 | 79 | def closed_by?(component) 80 | self.component.name == component.name && 81 | self.component.identifier == component.identifier && 82 | self.component.contexts == component.contexts 83 | end 84 | 85 | def to_s 86 | component.to_s 87 | end 88 | 89 | def <<(node) 90 | @nodes << node 91 | end 92 | 93 | def ==(other) 94 | other.type == type && 95 | other.component == component && 96 | other.nodes == nodes 97 | end 98 | end 99 | 100 | def self.parse(tokens) 101 | new(tokens).parse 102 | end 103 | 104 | def initialize(tokens) 105 | @tokens = tokens 106 | @root = Root.new 107 | @stack = [@root] 108 | end 109 | 110 | def parse 111 | @tokens.each do |token, *args| 112 | send("parse_#{token}", *args) 113 | end 114 | 115 | unless @stack.size == 1 116 | raise Curly::IncompleteBlockError, 117 | "block `#{@stack.last}` is not closed" 118 | end 119 | 120 | @root.nodes 121 | end 122 | 123 | private 124 | 125 | def parse_text(value) 126 | tree << Text.new(value) 127 | end 128 | 129 | def parse_component(*args) 130 | component = Component.new(*args) 131 | 132 | # If the component is namespaced by a list of context names, open a context 133 | # block for each. 134 | component.contexts.each do |context| 135 | parse_context_block_start(context) 136 | end 137 | 138 | tree << component 139 | 140 | # Close each context block in the namespace. 141 | component.contexts.reverse.each do |context| 142 | parse_block_end(context) 143 | end 144 | end 145 | 146 | def parse_conditional_block_start(*args) 147 | parse_block(:conditional, *args) 148 | end 149 | 150 | def parse_inverse_conditional_block_start(*args) 151 | parse_block(:inverse_conditional, *args) 152 | end 153 | 154 | def parse_collection_block_start(*args) 155 | parse_block(:collection, *args) 156 | end 157 | 158 | def parse_context_block_start(*args) 159 | parse_block(:context, *args) 160 | end 161 | 162 | def parse_block(type, *args) 163 | component = Component.new(*args) 164 | 165 | component.contexts.each do |context| 166 | parse_context_block_start(context) 167 | end 168 | 169 | block = Block.new(type, component) 170 | tree << block 171 | @stack.push(block) 172 | end 173 | 174 | def parse_block_end(*args) 175 | component = Component.new(*args) 176 | block = @stack.pop 177 | 178 | unless block.closed_by?(component) 179 | raise Curly::IncorrectEndingError, 180 | "block `#{block}` cannot be closed by `#{component}`" 181 | end 182 | 183 | component.contexts.reverse.each do |context| 184 | parse_block_end(context) 185 | end 186 | end 187 | 188 | def parse_comment(comment) 189 | tree << Comment.new(comment) 190 | end 191 | 192 | def tree 193 | @stack.last 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/curly/scanner.rb: -------------------------------------------------------------------------------- 1 | require 'strscan' 2 | require 'curly/component_scanner' 3 | require 'curly/syntax_error' 4 | 5 | module Curly 6 | 7 | # Scans Curly templates for tokens. 8 | # 9 | # The Scanner goes through the template piece by piece, extracting tokens 10 | # until the end of the template is reached. 11 | class Scanner 12 | CURLY_START = /\{\{/ 13 | CURLY_END = /\}\}/ 14 | 15 | ESCAPED_CURLY_START = /\{\{\{/ 16 | 17 | COMMENT_MARKER = /!/ 18 | CONTEXT_BLOCK_MARKER = /@/ 19 | CONDITIONAL_BLOCK_MARKER = /#/ 20 | INVERSE_BLOCK_MARKER = /\^/ 21 | COLLECTION_BLOCK_MARKER = /\*/ 22 | END_BLOCK_MARKER = /\// 23 | 24 | 25 | # Scans a Curly template for tokens. 26 | # 27 | # source - The String source of the template. 28 | # 29 | # Examples 30 | # 31 | # Curly::Scanner.scan("hello {{name}}!") 32 | # #=> [[:text, "hello "], [:component, "name"], [:text, "!"]] 33 | # 34 | # Returns an Array of type/value pairs representing the tokens in the 35 | # template. 36 | def self.scan(source) 37 | new(source).scan 38 | end 39 | 40 | def initialize(source) 41 | @scanner = StringScanner.new(source) 42 | end 43 | 44 | def scan 45 | tokens = [] 46 | tokens << scan_token until @scanner.eos? 47 | tokens 48 | end 49 | 50 | private 51 | 52 | # Scans the next token in the template. 53 | # 54 | # Returns a two-element Array, the first element being the Symbol type of 55 | # the token and the second being the String value. 56 | def scan_token 57 | scan_escaped_curly || scan_curly || scan_text 58 | end 59 | 60 | def scan_escaped_curly 61 | if @scanner.scan(ESCAPED_CURLY_START) 62 | [:text, "{{"] 63 | end 64 | end 65 | 66 | def scan_curly 67 | if @scanner.scan(CURLY_START) 68 | scan_tag or syntax_error! 69 | end 70 | end 71 | 72 | def scan_tag 73 | if @scanner.scan(COMMENT_MARKER) 74 | scan_comment 75 | elsif @scanner.scan(CONDITIONAL_BLOCK_MARKER) 76 | scan_conditional_block_start 77 | elsif @scanner.scan(CONTEXT_BLOCK_MARKER) 78 | scan_context_block_start 79 | elsif @scanner.scan(INVERSE_BLOCK_MARKER) 80 | scan_inverse_block_start 81 | elsif @scanner.scan(COLLECTION_BLOCK_MARKER) 82 | scan_collection_block_start 83 | elsif @scanner.scan(END_BLOCK_MARKER) 84 | scan_block_end 85 | else 86 | scan_component 87 | end 88 | end 89 | 90 | def scan_comment 91 | if value = scan_until_end_of_curly 92 | [:comment, value] 93 | end 94 | end 95 | 96 | def scan_conditional_block_start 97 | if value = scan_until_end_of_curly 98 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 99 | 100 | [:conditional_block_start, name, identifier, attributes, contexts] 101 | end 102 | end 103 | 104 | def scan_context_block_start 105 | if value = scan_until_end_of_curly 106 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 107 | 108 | [:context_block_start, name, identifier, attributes, contexts] 109 | end 110 | end 111 | 112 | def scan_collection_block_start 113 | if value = scan_until_end_of_curly 114 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 115 | [:collection_block_start, name, identifier, attributes, contexts] 116 | end 117 | end 118 | 119 | def scan_inverse_block_start 120 | if value = scan_until_end_of_curly 121 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 122 | [:inverse_conditional_block_start, name, identifier, attributes, contexts] 123 | end 124 | end 125 | 126 | def scan_block_end 127 | if value = scan_until_end_of_curly 128 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 129 | [:block_end, name, identifier, {}, contexts] 130 | end 131 | end 132 | 133 | def scan_component 134 | if value = scan_until_end_of_curly 135 | name, identifier, attributes, contexts = ComponentScanner.scan(value) 136 | [:component, name, identifier, attributes, contexts] 137 | end 138 | end 139 | 140 | def scan_text 141 | if value = scan_until_start_of_curly 142 | @scanner.pos -= 2 143 | [:text, value] 144 | else 145 | scan_remainder 146 | end 147 | end 148 | 149 | # Scans the remainder of the template and treats it as a text token. 150 | # 151 | # Returns an Array representing the token, or nil if no text is remaining. 152 | def scan_remainder 153 | if value = @scanner.scan(/.+/m) 154 | [:text, value] 155 | end 156 | end 157 | 158 | def scan_until_start_of_curly 159 | if value = @scanner.scan_until(CURLY_START) 160 | value[0..-3] 161 | end 162 | end 163 | 164 | def scan_until_end_of_curly 165 | if value = @scanner.scan_until(CURLY_END) 166 | value[0..-3] 167 | end 168 | end 169 | 170 | def syntax_error! 171 | raise SyntaxError.new(@scanner.pos, @scanner.string) 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/compiler_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Compiler do 2 | include CompilationSupport 3 | 4 | describe ".compile" do 5 | it "raises ArgumentError if the presenter class is nil" do 6 | expect { 7 | Curly::Compiler.compile("foo", nil) 8 | }.to raise_exception(ArgumentError) 9 | end 10 | 11 | it "makes sure only public methods are called on the presenter object" do 12 | expect { render("{{bar}}") }.to raise_exception(Curly::InvalidComponent) 13 | end 14 | 15 | it "includes the invalid component when failing to compile" do 16 | begin 17 | render("{{bar}}") 18 | fail 19 | rescue Curly::InvalidComponent => e 20 | expect(e.component).to eq "bar" 21 | end 22 | end 23 | 24 | it "propagates yields to the caller" do 25 | define_presenter do 26 | def i_want 27 | "I want #{yield}!" 28 | end 29 | end 30 | 31 | expect(render("{{i_want}}") { "$$$" }).to eq "I want $$$!" 32 | end 33 | 34 | it "sends along arguments passed to yield" do 35 | define_presenter do 36 | def hello(&block) 37 | "Hello, #{block.call('world')}!" 38 | end 39 | end 40 | 41 | expect(render("{{hello}}") {|v| v.upcase }).to eq "Hello, WORLD!" 42 | end 43 | 44 | it "escapes non HTML safe strings returned from the presenter" do 45 | define_presenter do 46 | def dirty 47 | "

    dirty

    " 48 | end 49 | end 50 | 51 | expect(render("{{dirty}}")).to eq "<p>dirty</p>" 52 | end 53 | 54 | it "does not escape HTML safe strings returned from the presenter" do 55 | define_presenter do 56 | def dirty 57 | "

    dirty

    ".html_safe 58 | end 59 | end 60 | 61 | expect(render("{{dirty}}")).to eq "

    dirty

    " 62 | end 63 | 64 | it "does not escape HTML in the template itself" do 65 | expect(render("
    ")).to eq "
    " 66 | end 67 | 68 | it "treats all values returned from the presenter as strings" do 69 | define_presenter do 70 | def foo; 42; end 71 | end 72 | 73 | expect(render("{{foo}}")).to eq "42" 74 | end 75 | 76 | it "removes comments from the output" do 77 | expect(render("hello{{! I'm a comment, yo }}world")).to eq "helloworld" 78 | end 79 | 80 | it "removes text in false blocks" do 81 | define_presenter do 82 | def false? 83 | false 84 | end 85 | end 86 | 87 | expect(render("{{#false?}}wut{{/false?}}")).to eq "" 88 | end 89 | 90 | it "keeps text in true blocks" do 91 | define_presenter do 92 | def true? 93 | true 94 | end 95 | end 96 | 97 | expect(render("{{#true?}}yello{{/true?}}")).to eq "yello" 98 | end 99 | 100 | it "removes text in inverse true blocks" do 101 | define_presenter do 102 | def true? 103 | true 104 | end 105 | end 106 | 107 | expect(render("{{^true?}}bar{{/true?}}")).to eq "" 108 | end 109 | 110 | it "keeps text in inverse false blocks" do 111 | define_presenter do 112 | def false? 113 | false 114 | end 115 | end 116 | 117 | expect(render("{{^false?}}yeah!{{/false?}}")).to eq "yeah!" 118 | end 119 | 120 | it "passes an argument to blocks" do 121 | define_presenter do 122 | def hello?(value) 123 | value == "world" 124 | end 125 | end 126 | 127 | expect(render("{{#hello.world?}}foo{{/hello.world?}}")).to eq "foo" 128 | expect(render("{{#hello.mars?}}bar{{/hello.mars?}}")).to eq "" 129 | end 130 | 131 | it "passes attributes to blocks" do 132 | define_presenter do 133 | def square?(width:, height:) 134 | width.to_i == height.to_i 135 | end 136 | end 137 | 138 | expect(render("{{#square? width=2 height=2}}yeah!{{/square?}}")).to eq "yeah!" 139 | end 140 | 141 | it "gives an error on incomplete blocks" do 142 | expect { 143 | render("{{#hello?}}") 144 | }.to raise_exception(Curly::IncompleteBlockError) 145 | end 146 | 147 | it "gives an error when closing unopened blocks" do 148 | expect { 149 | render("{{/goodbye?}}") 150 | }.to raise_exception(Curly::IncorrectEndingError) 151 | end 152 | 153 | it "gives an error on mismatching block ends" do 154 | expect { 155 | render("{{#x?}}{{#y?}}{{/x?}}{{/y?}}") 156 | }.to raise_exception(Curly::IncorrectEndingError) 157 | end 158 | 159 | it "does not execute arbitrary Ruby code" do 160 | expect(render('#{foo}')).to eq '#{foo}' 161 | end 162 | end 163 | 164 | describe ".valid?" do 165 | it "returns true if only available methods are referenced" do 166 | define_presenter do 167 | def foo; end 168 | end 169 | 170 | expect(validate("Hello, {{foo}}!")).to eq true 171 | end 172 | 173 | it "returns false if a missing method is referenced" do 174 | define_presenter 175 | expect(validate("Hello, {{i_am_missing}}")).to eq false 176 | end 177 | 178 | it "returns false if an unavailable method is referenced" do 179 | define_presenter do 180 | def self.available_components 181 | [] 182 | end 183 | end 184 | 185 | expect(validate("Hello, {{inspect}}")).to eq false 186 | end 187 | 188 | def validate(template) 189 | Curly.valid?(template, ShowPresenter) 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/curly/compiler.rb: -------------------------------------------------------------------------------- 1 | require 'curly/scanner' 2 | require 'curly/parser' 3 | require 'curly/component_compiler' 4 | require 'curly/error' 5 | require 'curly/invalid_component' 6 | 7 | module Curly 8 | 9 | # Compiles Curly templates into executable Ruby code. 10 | # 11 | # A template must be accompanied by a presenter class. This class defines the 12 | # components that are valid within the template. 13 | # 14 | class Compiler 15 | # Compiles a Curly template to Ruby code. 16 | # 17 | # template - The template String that should be compiled. 18 | # presenter_class - The presenter Class. 19 | # 20 | # Raises InvalidComponent if the template contains a component that is not 21 | # allowed. 22 | # Raises IncorrectEndingError if a conditional block is not ended in the 23 | # correct order - the most recent block must be ended first. 24 | # Raises IncompleteBlockError if a block is not completed. 25 | # Returns a String containing the Ruby code. 26 | def self.compile(template, presenter_class) 27 | if presenter_class.nil? 28 | raise ArgumentError, "presenter class cannot be nil" 29 | end 30 | 31 | tokens = Scanner.scan(template) 32 | nodes = Parser.parse(tokens) 33 | 34 | compiler = new(presenter_class) 35 | compiler.compile(nodes) 36 | compiler.code 37 | end 38 | 39 | # Whether the Curly template is valid. This includes whether all 40 | # components are available on the presenter class. 41 | # 42 | # template - The template String that should be validated. 43 | # presenter_class - The presenter Class. 44 | # 45 | # Returns true if the template is valid, false otherwise. 46 | def self.valid?(template, presenter_class) 47 | compile(template, presenter_class) 48 | 49 | true 50 | rescue Error 51 | false 52 | end 53 | 54 | def initialize(presenter_class) 55 | @presenter_classes = [presenter_class] 56 | @parts = [] 57 | end 58 | 59 | def compile(nodes) 60 | nodes.each do |node| 61 | send("compile_#{node.type}", node) 62 | end 63 | end 64 | 65 | def code 66 | <<-RUBY 67 | buffer = ActiveSupport::SafeBuffer.new 68 | buffers = [] 69 | presenters = [] 70 | options_stack = [] 71 | #{@parts.join("\n")} 72 | buffer 73 | RUBY 74 | end 75 | 76 | private 77 | 78 | def presenter_class 79 | @presenter_classes.last 80 | end 81 | 82 | def compile_conditional(block) 83 | compile_conditional_block("if", block) 84 | end 85 | 86 | def compile_inverse_conditional(block) 87 | compile_conditional_block("unless", block) 88 | end 89 | 90 | def compile_collection(block) 91 | component = block.component 92 | method_call = ComponentCompiler.compile(presenter_class, component) 93 | 94 | name = component.name.singularize 95 | counter = "#{name}_counter" 96 | 97 | item_presenter_class = presenter_class.presenter_for_name(name) 98 | 99 | output <<-RUBY 100 | presenters << presenter 101 | options_stack << options 102 | items = Array(#{method_call}) 103 | items.each_with_index do |item, index| 104 | options = options.merge("#{name}" => item, "#{counter}" => index + 1) 105 | presenter = ::#{item_presenter_class}.new(self, options) 106 | RUBY 107 | 108 | @presenter_classes.push(item_presenter_class) 109 | compile(block.nodes) 110 | @presenter_classes.pop 111 | 112 | output <<-RUBY 113 | end 114 | options = options_stack.pop 115 | presenter = presenters.pop 116 | RUBY 117 | end 118 | 119 | def compile_conditional_block(keyword, block) 120 | component = block.component 121 | method_call = ComponentCompiler.compile(presenter_class, component) 122 | 123 | unless component.name.end_with?("?") 124 | raise Curly::Error, "conditional components must end with `?`" 125 | end 126 | 127 | output <<-RUBY 128 | #{keyword} #{method_call} 129 | RUBY 130 | 131 | compile(block.nodes) 132 | 133 | output <<-RUBY 134 | end 135 | RUBY 136 | end 137 | 138 | def compile_context(block) 139 | component = block.component 140 | method_call = ComponentCompiler.compile(presenter_class, component, type: block.type) 141 | 142 | name = component.name 143 | 144 | item_presenter_class = presenter_class.presenter_for_name(name) 145 | 146 | output <<-RUBY 147 | options_stack << options 148 | presenters << presenter 149 | buffers << buffer 150 | buffer << #{method_call} do |item| 151 | options = options.merge("#{name}" => item) 152 | buffer = ActiveSupport::SafeBuffer.new 153 | presenter = ::#{item_presenter_class}.new(self, options) 154 | RUBY 155 | 156 | @presenter_classes.push(item_presenter_class) 157 | compile(block.nodes) 158 | @presenter_classes.pop 159 | 160 | output <<-RUBY 161 | buffer 162 | end 163 | buffer = buffers.pop 164 | presenter = presenters.pop 165 | options = options_stack.pop 166 | RUBY 167 | end 168 | 169 | def compile_component(component) 170 | method_call = ComponentCompiler.compile(presenter_class, component) 171 | code = "#{method_call} {|*args| yield(*args) }" 172 | 173 | output "buffer.concat(#{code.strip}.to_s)" 174 | end 175 | 176 | def compile_text(text) 177 | output "buffer.safe_concat(#{text.value.inspect})" 178 | end 179 | 180 | def compile_comment(comment) 181 | # Do nothing. 182 | end 183 | 184 | def output(code) 185 | @parts << code 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /gemfiles/rails7.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/zendesk/genspec.git 3 | revision: 76116991caf40ef940076f702f70a141ced84ce2 4 | branch: rails-7 5 | specs: 6 | genspec (0.3.2) 7 | rspec (>= 2, < 4) 8 | thor 9 | 10 | PATH 11 | remote: .. 12 | specs: 13 | curly-templates (3.4.0) 14 | actionpack (>= 6.1) 15 | sorted_set 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | actioncable (7.0.8) 21 | actionpack (= 7.0.8) 22 | activesupport (= 7.0.8) 23 | nio4r (~> 2.0) 24 | websocket-driver (>= 0.6.1) 25 | actionmailbox (7.0.8) 26 | actionpack (= 7.0.8) 27 | activejob (= 7.0.8) 28 | activerecord (= 7.0.8) 29 | activestorage (= 7.0.8) 30 | activesupport (= 7.0.8) 31 | mail (>= 2.7.1) 32 | net-imap 33 | net-pop 34 | net-smtp 35 | actionmailer (7.0.8) 36 | actionpack (= 7.0.8) 37 | actionview (= 7.0.8) 38 | activejob (= 7.0.8) 39 | activesupport (= 7.0.8) 40 | mail (~> 2.5, >= 2.5.4) 41 | net-imap 42 | net-pop 43 | net-smtp 44 | rails-dom-testing (~> 2.0) 45 | actionpack (7.0.8) 46 | actionview (= 7.0.8) 47 | activesupport (= 7.0.8) 48 | rack (~> 2.0, >= 2.2.4) 49 | rack-test (>= 0.6.3) 50 | rails-dom-testing (~> 2.0) 51 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 52 | actiontext (7.0.8) 53 | actionpack (= 7.0.8) 54 | activerecord (= 7.0.8) 55 | activestorage (= 7.0.8) 56 | activesupport (= 7.0.8) 57 | globalid (>= 0.6.0) 58 | nokogiri (>= 1.8.5) 59 | actionview (7.0.8) 60 | activesupport (= 7.0.8) 61 | builder (~> 3.1) 62 | erubi (~> 1.4) 63 | rails-dom-testing (~> 2.0) 64 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 65 | activejob (7.0.8) 66 | activesupport (= 7.0.8) 67 | globalid (>= 0.3.6) 68 | activemodel (7.0.8) 69 | activesupport (= 7.0.8) 70 | activerecord (7.0.8) 71 | activemodel (= 7.0.8) 72 | activesupport (= 7.0.8) 73 | activestorage (7.0.8) 74 | actionpack (= 7.0.8) 75 | activejob (= 7.0.8) 76 | activerecord (= 7.0.8) 77 | activesupport (= 7.0.8) 78 | marcel (~> 1.0) 79 | mini_mime (>= 1.1.0) 80 | activesupport (7.0.8) 81 | concurrent-ruby (~> 1.0, >= 1.0.2) 82 | i18n (>= 1.6, < 2) 83 | minitest (>= 5.1) 84 | tzinfo (~> 2.0) 85 | benchmark-ips (2.12.0) 86 | builder (3.2.4) 87 | concurrent-ruby (1.2.2) 88 | crass (1.0.6) 89 | date (3.3.4) 90 | diff-lcs (1.5.0) 91 | erubi (1.12.0) 92 | github-markup (4.0.2) 93 | globalid (1.2.1) 94 | activesupport (>= 6.1) 95 | i18n (1.14.1) 96 | concurrent-ruby (~> 1.0) 97 | loofah (2.21.4) 98 | crass (~> 1.0.2) 99 | nokogiri (>= 1.12.0) 100 | mail (2.8.1) 101 | mini_mime (>= 0.1.1) 102 | net-imap 103 | net-pop 104 | net-smtp 105 | marcel (1.0.2) 106 | method_source (1.0.0) 107 | mini_mime (1.1.5) 108 | mini_portile2 (2.8.5) 109 | minitest (5.20.0) 110 | net-imap (0.4.5) 111 | date 112 | net-protocol 113 | net-pop (0.1.2) 114 | net-protocol 115 | net-protocol (0.2.2) 116 | timeout 117 | net-smtp (0.4.0) 118 | net-protocol 119 | nio4r (2.5.9) 120 | nokogiri (1.15.4) 121 | mini_portile2 (~> 2.8.2) 122 | racc (~> 1.4) 123 | racc (1.7.3) 124 | rack (2.2.8) 125 | rack-test (2.1.0) 126 | rack (>= 1.3) 127 | rails (7.0.8) 128 | actioncable (= 7.0.8) 129 | actionmailbox (= 7.0.8) 130 | actionmailer (= 7.0.8) 131 | actionpack (= 7.0.8) 132 | actiontext (= 7.0.8) 133 | actionview (= 7.0.8) 134 | activejob (= 7.0.8) 135 | activemodel (= 7.0.8) 136 | activerecord (= 7.0.8) 137 | activestorage (= 7.0.8) 138 | activesupport (= 7.0.8) 139 | bundler (>= 1.15.0) 140 | railties (= 7.0.8) 141 | rails-dom-testing (2.2.0) 142 | activesupport (>= 5.0.0) 143 | minitest 144 | nokogiri (>= 1.6) 145 | rails-html-sanitizer (1.6.0) 146 | loofah (~> 2.21) 147 | nokogiri (~> 1.14) 148 | railties (7.0.8) 149 | actionpack (= 7.0.8) 150 | activesupport (= 7.0.8) 151 | method_source 152 | rake (>= 12.2) 153 | thor (~> 1.0) 154 | zeitwerk (~> 2.5) 155 | rake (13.1.0) 156 | rbtree (0.4.6) 157 | redcarpet (3.6.0) 158 | rspec (3.12.0) 159 | rspec-core (~> 3.12.0) 160 | rspec-expectations (~> 3.12.0) 161 | rspec-mocks (~> 3.12.0) 162 | rspec-core (3.12.2) 163 | rspec-support (~> 3.12.0) 164 | rspec-expectations (3.12.3) 165 | diff-lcs (>= 1.2.0, < 2.0) 166 | rspec-support (~> 3.12.0) 167 | rspec-mocks (3.12.6) 168 | diff-lcs (>= 1.2.0, < 2.0) 169 | rspec-support (~> 3.12.0) 170 | rspec-rails (6.0.3) 171 | actionpack (>= 6.1) 172 | activesupport (>= 6.1) 173 | railties (>= 6.1) 174 | rspec-core (~> 3.12) 175 | rspec-expectations (~> 3.12) 176 | rspec-mocks (~> 3.12) 177 | rspec-support (~> 3.12) 178 | rspec-support (3.12.1) 179 | set (1.0.3) 180 | sorted_set (1.0.3) 181 | rbtree 182 | set (~> 1.0) 183 | stackprof (0.2.25) 184 | thor (1.3.0) 185 | timeout (0.4.1) 186 | tomparse (0.4.2) 187 | tzinfo (2.0.6) 188 | concurrent-ruby (~> 1.0) 189 | websocket-driver (0.7.6) 190 | websocket-extensions (>= 0.1.0) 191 | websocket-extensions (0.1.5) 192 | yard (0.9.34) 193 | yard-tomdoc (0.7.1) 194 | tomparse (>= 0.4.0) 195 | yard 196 | zeitwerk (2.6.12) 197 | 198 | PLATFORMS 199 | ruby 200 | 201 | DEPENDENCIES 202 | benchmark-ips 203 | curly-templates! 204 | genspec! 205 | github-markup 206 | rails (~> 7.0.0) 207 | rake 208 | redcarpet 209 | rspec (>= 3) 210 | rspec-rails 211 | stackprof 212 | yard 213 | yard-tomdoc 214 | 215 | BUNDLED WITH 216 | 2.4.17 217 | -------------------------------------------------------------------------------- /spec/template_handler_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::TemplateHandler do 2 | let :presenter_class do 3 | Class.new do 4 | def initialize(context, options = {}) 5 | @context = context 6 | @cache_key = options.fetch(:cache_key, nil) 7 | @cache_duration = options.fetch(:cache_duration, nil) 8 | @cache_options = options.fetch(:cache_options, {}) 9 | end 10 | 11 | def setup! 12 | @context.content_for(:foo, "bar") 13 | end 14 | 15 | def foo 16 | "FOO" 17 | end 18 | 19 | def bar 20 | @context.bar 21 | end 22 | 23 | def cache_key 24 | @cache_key 25 | end 26 | 27 | def cache_duration 28 | @cache_duration 29 | end 30 | 31 | def cache_options 32 | @cache_options 33 | end 34 | 35 | def self.component_available?(method) 36 | true 37 | end 38 | end 39 | end 40 | 41 | let :context_class do 42 | Class.new do 43 | attr_reader :output_buffer 44 | attr_reader :local_assigns, :assigns 45 | 46 | def initialize 47 | @cache = Hash.new 48 | @local_assigns = Hash.new 49 | @assigns = Hash.new 50 | @clock = 0 51 | end 52 | 53 | def reset! 54 | @output_buffer = ActiveSupport::SafeBuffer.new 55 | end 56 | 57 | def advance_clock(duration) 58 | @clock += duration 59 | end 60 | 61 | def content_for(key, value = nil) 62 | @contents ||= {} 63 | @contents[key] = value if value.present? 64 | @contents[key] 65 | end 66 | 67 | def cache(key, options = {}) 68 | fragment, expired_at = @cache[key] 69 | 70 | if fragment.nil? || @clock >= expired_at 71 | old_buffer = @output_buffer 72 | @output_buffer = ActiveSupport::SafeBuffer.new 73 | 74 | yield 75 | 76 | fragment = @output_buffer.to_s 77 | duration = options[:expires_in] || Float::INFINITY 78 | 79 | @cache[key] = [fragment, @clock + duration] 80 | 81 | @output_buffer = old_buffer 82 | end 83 | 84 | safe_concat(fragment) 85 | 86 | nil 87 | end 88 | 89 | def safe_concat(str) 90 | @output_buffer.safe_concat(str) 91 | end 92 | end 93 | end 94 | 95 | let(:template) { double("template", virtual_path: "test") } 96 | let(:context) { context_class.new } 97 | 98 | before do 99 | stub_const("TestPresenter", presenter_class) 100 | end 101 | 102 | it "passes in the presenter context to the presenter class" do 103 | allow(context).to receive(:bar) { "BAR" } 104 | output = render("{{bar}}") 105 | expect(output).to eq "BAR" 106 | end 107 | 108 | it "should fail if there's no matching presenter class" do 109 | allow(template).to receive(:virtual_path) { "missing" } 110 | expect { render(" FOO ") }.to raise_exception(Curly::PresenterNotFound) 111 | end 112 | 113 | it "allows calling public methods on the presenter" do 114 | output = render("{{foo}}") 115 | expect(output).to eq "FOO" 116 | end 117 | 118 | it "marks its output as HTML safe" do 119 | output = render("{{foo}}") 120 | expect(output).to be_html_safe 121 | end 122 | 123 | it "calls the #setup! method before rendering the view" do 124 | output = render("{{foo}}") 125 | output 126 | expect(context.content_for(:foo)).to eq "bar" 127 | end 128 | 129 | context "caching" do 130 | before do 131 | allow(context).to receive(:bar) { "BAR" } 132 | end 133 | 134 | let(:output) { -> { render("{{bar}}") } } 135 | 136 | it "caches the result with the #cache_key from the presenter" do 137 | context.assigns[:cache_key] = "x" 138 | expect(output.call).to eq "BAR" 139 | 140 | allow(context).to receive(:bar) { "BAZ" } 141 | expect(output.call).to eq "BAR" 142 | 143 | context.assigns[:cache_key] = "y" 144 | expect(output.call).to eq "BAZ" 145 | end 146 | 147 | it "doesn't cache when the cache key is nil" do 148 | context.assigns[:cache_key] = nil 149 | expect(output.call).to eq "BAR" 150 | 151 | allow(context).to receive(:bar) { "BAZ" } 152 | expect(output.call).to eq "BAZ" 153 | end 154 | 155 | it "adds the presenter class' cache key to the instance's cache key" do 156 | # Make sure caching is enabled 157 | context.assigns[:cache_key] = "x" 158 | 159 | allow(presenter_class).to receive(:cache_key) { "foo" } 160 | 161 | expect(output.call).to eq "BAR" 162 | 163 | allow(presenter_class).to receive(:cache_key) { "bar" } 164 | 165 | allow(context).to receive(:bar) { "FOOBAR" } 166 | expect(output.call).to eq "FOOBAR" 167 | end 168 | 169 | it "expires the cache keys after #cache_duration" do 170 | context.assigns[:cache_key] = "x" 171 | context.assigns[:cache_duration] = 42 172 | 173 | expect(output.call).to eq "BAR" 174 | 175 | allow(context).to receive(:bar) { "FOO" } 176 | 177 | # Cached fragment has not yet expired. 178 | context.advance_clock(41) 179 | expect(output.call).to eq "BAR" 180 | 181 | # Now it has! Huzzah! 182 | context.advance_clock(1) 183 | expect(output.call).to eq "FOO" 184 | end 185 | 186 | it "passes #cache_options to the cache backend" do 187 | context.assigns[:cache_key] = "x" 188 | context.assigns[:cache_options] = { expires_in: 42 } 189 | 190 | expect(output.call).to eq "BAR" 191 | 192 | allow(context).to receive(:bar) { "FOO" } 193 | 194 | # Cached fragment has not yet expired. 195 | context.advance_clock(41) 196 | expect(output.call).to eq "BAR" 197 | 198 | # Now it has! Huzzah! 199 | context.advance_clock(1) 200 | expect(output.call).to eq "FOO" 201 | end 202 | end 203 | 204 | def render(source) 205 | code = Curly::TemplateHandler.call(template, source) 206 | 207 | context.reset! 208 | context.instance_eval(code) 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /gemfiles/rails6.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/zendesk/genspec.git 3 | revision: 76116991caf40ef940076f702f70a141ced84ce2 4 | branch: rails-7 5 | specs: 6 | genspec (0.3.2) 7 | rspec (>= 2, < 4) 8 | thor 9 | 10 | PATH 11 | remote: .. 12 | specs: 13 | curly-templates (3.4.0) 14 | actionpack (>= 6.1) 15 | sorted_set 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | actioncable (6.1.7.6) 21 | actionpack (= 6.1.7.6) 22 | activesupport (= 6.1.7.6) 23 | nio4r (~> 2.0) 24 | websocket-driver (>= 0.6.1) 25 | actionmailbox (6.1.7.6) 26 | actionpack (= 6.1.7.6) 27 | activejob (= 6.1.7.6) 28 | activerecord (= 6.1.7.6) 29 | activestorage (= 6.1.7.6) 30 | activesupport (= 6.1.7.6) 31 | mail (>= 2.7.1) 32 | actionmailer (6.1.7.6) 33 | actionpack (= 6.1.7.6) 34 | actionview (= 6.1.7.6) 35 | activejob (= 6.1.7.6) 36 | activesupport (= 6.1.7.6) 37 | mail (~> 2.5, >= 2.5.4) 38 | rails-dom-testing (~> 2.0) 39 | actionpack (6.1.7.6) 40 | actionview (= 6.1.7.6) 41 | activesupport (= 6.1.7.6) 42 | rack (~> 2.0, >= 2.0.9) 43 | rack-test (>= 0.6.3) 44 | rails-dom-testing (~> 2.0) 45 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 46 | actiontext (6.1.7.6) 47 | actionpack (= 6.1.7.6) 48 | activerecord (= 6.1.7.6) 49 | activestorage (= 6.1.7.6) 50 | activesupport (= 6.1.7.6) 51 | nokogiri (>= 1.8.5) 52 | actionview (6.1.7.6) 53 | activesupport (= 6.1.7.6) 54 | builder (~> 3.1) 55 | erubi (~> 1.4) 56 | rails-dom-testing (~> 2.0) 57 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 58 | activejob (6.1.7.6) 59 | activesupport (= 6.1.7.6) 60 | globalid (>= 0.3.6) 61 | activemodel (6.1.7.6) 62 | activesupport (= 6.1.7.6) 63 | activerecord (6.1.7.6) 64 | activemodel (= 6.1.7.6) 65 | activesupport (= 6.1.7.6) 66 | activestorage (6.1.7.6) 67 | actionpack (= 6.1.7.6) 68 | activejob (= 6.1.7.6) 69 | activerecord (= 6.1.7.6) 70 | activesupport (= 6.1.7.6) 71 | marcel (~> 1.0) 72 | mini_mime (>= 1.1.0) 73 | activesupport (6.1.7.6) 74 | concurrent-ruby (~> 1.0, >= 1.0.2) 75 | i18n (>= 1.6, < 2) 76 | minitest (>= 5.1) 77 | tzinfo (~> 2.0) 78 | zeitwerk (~> 2.3) 79 | benchmark-ips (2.12.0) 80 | builder (3.2.4) 81 | concurrent-ruby (1.2.2) 82 | crass (1.0.6) 83 | date (3.3.4) 84 | diff-lcs (1.5.0) 85 | erubi (1.12.0) 86 | github-markup (4.0.2) 87 | globalid (1.2.1) 88 | activesupport (>= 6.1) 89 | i18n (1.14.1) 90 | concurrent-ruby (~> 1.0) 91 | loofah (2.21.4) 92 | crass (~> 1.0.2) 93 | nokogiri (>= 1.12.0) 94 | mail (2.8.1) 95 | mini_mime (>= 0.1.1) 96 | net-imap 97 | net-pop 98 | net-smtp 99 | marcel (1.0.2) 100 | method_source (1.0.0) 101 | mini_mime (1.1.5) 102 | mini_portile2 (2.8.5) 103 | minitest (5.20.0) 104 | net-imap (0.4.5) 105 | date 106 | net-protocol 107 | net-pop (0.1.2) 108 | net-protocol 109 | net-protocol (0.2.2) 110 | timeout 111 | net-smtp (0.4.0) 112 | net-protocol 113 | nio4r (2.5.9) 114 | nokogiri (1.15.4) 115 | mini_portile2 (~> 2.8.2) 116 | racc (~> 1.4) 117 | racc (1.7.3) 118 | rack (2.2.8) 119 | rack-test (2.1.0) 120 | rack (>= 1.3) 121 | rails (6.1.7.6) 122 | actioncable (= 6.1.7.6) 123 | actionmailbox (= 6.1.7.6) 124 | actionmailer (= 6.1.7.6) 125 | actionpack (= 6.1.7.6) 126 | actiontext (= 6.1.7.6) 127 | actionview (= 6.1.7.6) 128 | activejob (= 6.1.7.6) 129 | activemodel (= 6.1.7.6) 130 | activerecord (= 6.1.7.6) 131 | activestorage (= 6.1.7.6) 132 | activesupport (= 6.1.7.6) 133 | bundler (>= 1.15.0) 134 | railties (= 6.1.7.6) 135 | sprockets-rails (>= 2.0.0) 136 | rails-dom-testing (2.2.0) 137 | activesupport (>= 5.0.0) 138 | minitest 139 | nokogiri (>= 1.6) 140 | rails-html-sanitizer (1.6.0) 141 | loofah (~> 2.21) 142 | nokogiri (~> 1.14) 143 | railties (6.1.7.6) 144 | actionpack (= 6.1.7.6) 145 | activesupport (= 6.1.7.6) 146 | method_source 147 | rake (>= 12.2) 148 | thor (~> 1.0) 149 | rake (13.1.0) 150 | rbtree (0.4.6) 151 | redcarpet (3.6.0) 152 | rspec (3.12.0) 153 | rspec-core (~> 3.12.0) 154 | rspec-expectations (~> 3.12.0) 155 | rspec-mocks (~> 3.12.0) 156 | rspec-core (3.12.2) 157 | rspec-support (~> 3.12.0) 158 | rspec-expectations (3.12.3) 159 | diff-lcs (>= 1.2.0, < 2.0) 160 | rspec-support (~> 3.12.0) 161 | rspec-mocks (3.12.6) 162 | diff-lcs (>= 1.2.0, < 2.0) 163 | rspec-support (~> 3.12.0) 164 | rspec-rails (6.0.3) 165 | actionpack (>= 6.1) 166 | activesupport (>= 6.1) 167 | railties (>= 6.1) 168 | rspec-core (~> 3.12) 169 | rspec-expectations (~> 3.12) 170 | rspec-mocks (~> 3.12) 171 | rspec-support (~> 3.12) 172 | rspec-support (3.12.1) 173 | set (1.0.3) 174 | sorted_set (1.0.3) 175 | rbtree 176 | set (~> 1.0) 177 | sprockets (4.2.1) 178 | concurrent-ruby (~> 1.0) 179 | rack (>= 2.2.4, < 4) 180 | sprockets-rails (3.4.2) 181 | actionpack (>= 5.2) 182 | activesupport (>= 5.2) 183 | sprockets (>= 3.0.0) 184 | stackprof (0.2.25) 185 | thor (1.3.0) 186 | timeout (0.4.1) 187 | tomparse (0.4.2) 188 | tzinfo (2.0.6) 189 | concurrent-ruby (~> 1.0) 190 | websocket-driver (0.7.6) 191 | websocket-extensions (>= 0.1.0) 192 | websocket-extensions (0.1.5) 193 | yard (0.9.34) 194 | yard-tomdoc (0.7.1) 195 | tomparse (>= 0.4.0) 196 | yard 197 | zeitwerk (2.6.12) 198 | 199 | PLATFORMS 200 | ruby 201 | 202 | DEPENDENCIES 203 | benchmark-ips 204 | curly-templates! 205 | genspec! 206 | github-markup 207 | rails (~> 6.1.0) 208 | rake 209 | redcarpet 210 | rspec (>= 3) 211 | rspec-rails 212 | stackprof 213 | yard 214 | yard-tomdoc 215 | 216 | BUNDLED WITH 217 | 2.4.17 218 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/zendesk/genspec.git 3 | revision: 76116991caf40ef940076f702f70a141ced84ce2 4 | branch: rails-7 5 | specs: 6 | genspec (0.3.2) 7 | rspec (>= 2, < 4) 8 | thor 9 | 10 | PATH 11 | remote: .. 12 | specs: 13 | curly-templates (3.4.0) 14 | actionpack (>= 6.1) 15 | sorted_set 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | actioncable (7.1.2) 21 | actionpack (= 7.1.2) 22 | activesupport (= 7.1.2) 23 | nio4r (~> 2.0) 24 | websocket-driver (>= 0.6.1) 25 | zeitwerk (~> 2.6) 26 | actionmailbox (7.1.2) 27 | actionpack (= 7.1.2) 28 | activejob (= 7.1.2) 29 | activerecord (= 7.1.2) 30 | activestorage (= 7.1.2) 31 | activesupport (= 7.1.2) 32 | mail (>= 2.7.1) 33 | net-imap 34 | net-pop 35 | net-smtp 36 | actionmailer (7.1.2) 37 | actionpack (= 7.1.2) 38 | actionview (= 7.1.2) 39 | activejob (= 7.1.2) 40 | activesupport (= 7.1.2) 41 | mail (~> 2.5, >= 2.5.4) 42 | net-imap 43 | net-pop 44 | net-smtp 45 | rails-dom-testing (~> 2.2) 46 | actionpack (7.1.2) 47 | actionview (= 7.1.2) 48 | activesupport (= 7.1.2) 49 | nokogiri (>= 1.8.5) 50 | racc 51 | rack (>= 2.2.4) 52 | rack-session (>= 1.0.1) 53 | rack-test (>= 0.6.3) 54 | rails-dom-testing (~> 2.2) 55 | rails-html-sanitizer (~> 1.6) 56 | actiontext (7.1.2) 57 | actionpack (= 7.1.2) 58 | activerecord (= 7.1.2) 59 | activestorage (= 7.1.2) 60 | activesupport (= 7.1.2) 61 | globalid (>= 0.6.0) 62 | nokogiri (>= 1.8.5) 63 | actionview (7.1.2) 64 | activesupport (= 7.1.2) 65 | builder (~> 3.1) 66 | erubi (~> 1.11) 67 | rails-dom-testing (~> 2.2) 68 | rails-html-sanitizer (~> 1.6) 69 | activejob (7.1.2) 70 | activesupport (= 7.1.2) 71 | globalid (>= 0.3.6) 72 | activemodel (7.1.2) 73 | activesupport (= 7.1.2) 74 | activerecord (7.1.2) 75 | activemodel (= 7.1.2) 76 | activesupport (= 7.1.2) 77 | timeout (>= 0.4.0) 78 | activestorage (7.1.2) 79 | actionpack (= 7.1.2) 80 | activejob (= 7.1.2) 81 | activerecord (= 7.1.2) 82 | activesupport (= 7.1.2) 83 | marcel (~> 1.0) 84 | activesupport (7.1.2) 85 | base64 86 | bigdecimal 87 | concurrent-ruby (~> 1.0, >= 1.0.2) 88 | connection_pool (>= 2.2.5) 89 | drb 90 | i18n (>= 1.6, < 2) 91 | minitest (>= 5.1) 92 | mutex_m 93 | tzinfo (~> 2.0) 94 | base64 (0.2.0) 95 | benchmark-ips (2.12.0) 96 | bigdecimal (3.1.4) 97 | builder (3.2.4) 98 | concurrent-ruby (1.2.2) 99 | connection_pool (2.4.1) 100 | crass (1.0.6) 101 | date (3.3.4) 102 | diff-lcs (1.5.0) 103 | drb (2.2.1) 104 | erubi (1.12.0) 105 | github-markup (4.0.2) 106 | globalid (1.2.1) 107 | activesupport (>= 6.1) 108 | i18n (1.14.1) 109 | concurrent-ruby (~> 1.0) 110 | io-console (0.6.0) 111 | irb (1.9.0) 112 | rdoc 113 | reline (>= 0.3.8) 114 | loofah (2.21.4) 115 | crass (~> 1.0.2) 116 | nokogiri (>= 1.12.0) 117 | mail (2.8.1) 118 | mini_mime (>= 0.1.1) 119 | net-imap 120 | net-pop 121 | net-smtp 122 | marcel (1.0.2) 123 | mini_mime (1.1.5) 124 | mini_portile2 (2.8.5) 125 | minitest (5.20.0) 126 | mutex_m (0.2.0) 127 | net-imap (0.4.5) 128 | date 129 | net-protocol 130 | net-pop (0.1.2) 131 | net-protocol 132 | net-protocol (0.2.2) 133 | timeout 134 | net-smtp (0.4.0) 135 | net-protocol 136 | nio4r (2.5.9) 137 | nokogiri (1.15.4) 138 | mini_portile2 (~> 2.8.2) 139 | racc (~> 1.4) 140 | psych (5.1.1.1) 141 | stringio 142 | racc (1.7.3) 143 | rack (3.0.8) 144 | rack-session (2.0.0) 145 | rack (>= 3.0.0) 146 | rack-test (2.1.0) 147 | rack (>= 1.3) 148 | rackup (2.1.0) 149 | rack (>= 3) 150 | webrick (~> 1.8) 151 | rails (7.1.2) 152 | actioncable (= 7.1.2) 153 | actionmailbox (= 7.1.2) 154 | actionmailer (= 7.1.2) 155 | actionpack (= 7.1.2) 156 | actiontext (= 7.1.2) 157 | actionview (= 7.1.2) 158 | activejob (= 7.1.2) 159 | activemodel (= 7.1.2) 160 | activerecord (= 7.1.2) 161 | activestorage (= 7.1.2) 162 | activesupport (= 7.1.2) 163 | bundler (>= 1.15.0) 164 | railties (= 7.1.2) 165 | rails-dom-testing (2.2.0) 166 | activesupport (>= 5.0.0) 167 | minitest 168 | nokogiri (>= 1.6) 169 | rails-html-sanitizer (1.6.0) 170 | loofah (~> 2.21) 171 | nokogiri (~> 1.14) 172 | railties (7.1.2) 173 | actionpack (= 7.1.2) 174 | activesupport (= 7.1.2) 175 | irb 176 | rackup (>= 1.0.0) 177 | rake (>= 12.2) 178 | thor (~> 1.0, >= 1.2.2) 179 | zeitwerk (~> 2.6) 180 | rake (13.1.0) 181 | rbtree (0.4.6) 182 | rdoc (6.6.0) 183 | psych (>= 4.0.0) 184 | redcarpet (3.6.0) 185 | reline (0.4.0) 186 | io-console (~> 0.5) 187 | rspec (3.12.0) 188 | rspec-core (~> 3.12.0) 189 | rspec-expectations (~> 3.12.0) 190 | rspec-mocks (~> 3.12.0) 191 | rspec-core (3.12.2) 192 | rspec-support (~> 3.12.0) 193 | rspec-expectations (3.12.3) 194 | diff-lcs (>= 1.2.0, < 2.0) 195 | rspec-support (~> 3.12.0) 196 | rspec-mocks (3.12.6) 197 | diff-lcs (>= 1.2.0, < 2.0) 198 | rspec-support (~> 3.12.0) 199 | rspec-rails (6.0.3) 200 | actionpack (>= 6.1) 201 | activesupport (>= 6.1) 202 | railties (>= 6.1) 203 | rspec-core (~> 3.12) 204 | rspec-expectations (~> 3.12) 205 | rspec-mocks (~> 3.12) 206 | rspec-support (~> 3.12) 207 | rspec-support (3.12.1) 208 | set (1.0.3) 209 | sorted_set (1.0.3) 210 | rbtree 211 | set (~> 1.0) 212 | stackprof (0.2.25) 213 | stringio (3.0.9) 214 | thor (1.3.0) 215 | timeout (0.4.1) 216 | tomparse (0.4.2) 217 | tzinfo (2.0.6) 218 | concurrent-ruby (~> 1.0) 219 | webrick (1.8.1) 220 | websocket-driver (0.7.6) 221 | websocket-extensions (>= 0.1.0) 222 | websocket-extensions (0.1.5) 223 | yard (0.9.34) 224 | yard-tomdoc (0.7.1) 225 | tomparse (>= 0.4.0) 226 | yard 227 | zeitwerk (2.6.12) 228 | 229 | PLATFORMS 230 | ruby 231 | 232 | DEPENDENCIES 233 | benchmark-ips 234 | curly-templates! 235 | genspec! 236 | github-markup 237 | rails (~> 7.1.0) 238 | rake 239 | redcarpet 240 | rspec (>= 3) 241 | rspec-rails 242 | stackprof 243 | yard 244 | yard-tomdoc 245 | 246 | BUNDLED WITH 247 | 2.4.17 248 | -------------------------------------------------------------------------------- /gemfiles/rails8.0.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/zendesk/genspec.git 3 | revision: 20caa3263a6780aaf40629b8137f0770c849bc49 4 | branch: rails-8 5 | specs: 6 | genspec (0.3.2) 7 | rspec (>= 2, < 4) 8 | thor 9 | 10 | PATH 11 | remote: .. 12 | specs: 13 | curly-templates (3.4.0) 14 | actionpack (>= 6.1) 15 | sorted_set 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | actioncable (8.0.1) 21 | actionpack (= 8.0.1) 22 | activesupport (= 8.0.1) 23 | nio4r (~> 2.0) 24 | websocket-driver (>= 0.6.1) 25 | zeitwerk (~> 2.6) 26 | actionmailbox (8.0.1) 27 | actionpack (= 8.0.1) 28 | activejob (= 8.0.1) 29 | activerecord (= 8.0.1) 30 | activestorage (= 8.0.1) 31 | activesupport (= 8.0.1) 32 | mail (>= 2.8.0) 33 | actionmailer (8.0.1) 34 | actionpack (= 8.0.1) 35 | actionview (= 8.0.1) 36 | activejob (= 8.0.1) 37 | activesupport (= 8.0.1) 38 | mail (>= 2.8.0) 39 | rails-dom-testing (~> 2.2) 40 | actionpack (8.0.1) 41 | actionview (= 8.0.1) 42 | activesupport (= 8.0.1) 43 | nokogiri (>= 1.8.5) 44 | rack (>= 2.2.4) 45 | rack-session (>= 1.0.1) 46 | rack-test (>= 0.6.3) 47 | rails-dom-testing (~> 2.2) 48 | rails-html-sanitizer (~> 1.6) 49 | useragent (~> 0.16) 50 | actiontext (8.0.1) 51 | actionpack (= 8.0.1) 52 | activerecord (= 8.0.1) 53 | activestorage (= 8.0.1) 54 | activesupport (= 8.0.1) 55 | globalid (>= 0.6.0) 56 | nokogiri (>= 1.8.5) 57 | actionview (8.0.1) 58 | activesupport (= 8.0.1) 59 | builder (~> 3.1) 60 | erubi (~> 1.11) 61 | rails-dom-testing (~> 2.2) 62 | rails-html-sanitizer (~> 1.6) 63 | activejob (8.0.1) 64 | activesupport (= 8.0.1) 65 | globalid (>= 0.3.6) 66 | activemodel (8.0.1) 67 | activesupport (= 8.0.1) 68 | activerecord (8.0.1) 69 | activemodel (= 8.0.1) 70 | activesupport (= 8.0.1) 71 | timeout (>= 0.4.0) 72 | activestorage (8.0.1) 73 | actionpack (= 8.0.1) 74 | activejob (= 8.0.1) 75 | activerecord (= 8.0.1) 76 | activesupport (= 8.0.1) 77 | marcel (~> 1.0) 78 | activesupport (8.0.1) 79 | base64 80 | benchmark (>= 0.3) 81 | bigdecimal 82 | concurrent-ruby (~> 1.0, >= 1.3.1) 83 | connection_pool (>= 2.2.5) 84 | drb 85 | i18n (>= 1.6, < 2) 86 | logger (>= 1.4.2) 87 | minitest (>= 5.1) 88 | securerandom (>= 0.3) 89 | tzinfo (~> 2.0, >= 2.0.5) 90 | uri (>= 0.13.1) 91 | base64 (0.2.0) 92 | benchmark (0.4.0) 93 | benchmark-ips (2.14.0) 94 | bigdecimal (3.1.9) 95 | builder (3.3.0) 96 | concurrent-ruby (1.3.4) 97 | connection_pool (2.4.1) 98 | crass (1.0.6) 99 | date (3.4.1) 100 | diff-lcs (1.5.1) 101 | drb (2.2.1) 102 | erubi (1.13.1) 103 | github-markup (5.0.1) 104 | globalid (1.2.1) 105 | activesupport (>= 6.1) 106 | i18n (1.14.6) 107 | concurrent-ruby (~> 1.0) 108 | io-console (0.8.0) 109 | irb (1.14.3) 110 | rdoc (>= 4.0.0) 111 | reline (>= 0.4.2) 112 | logger (1.6.4) 113 | loofah (2.23.1) 114 | crass (~> 1.0.2) 115 | nokogiri (>= 1.12.0) 116 | mail (2.8.1) 117 | mini_mime (>= 0.1.1) 118 | net-imap 119 | net-pop 120 | net-smtp 121 | marcel (1.0.4) 122 | mini_mime (1.1.5) 123 | mini_portile2 (2.8.8) 124 | minitest (5.25.4) 125 | net-imap (0.5.4) 126 | date 127 | net-protocol 128 | net-pop (0.1.2) 129 | net-protocol 130 | net-protocol (0.2.2) 131 | timeout 132 | net-smtp (0.5.0) 133 | net-protocol 134 | nio4r (2.7.4) 135 | nokogiri (1.18.1) 136 | mini_portile2 (~> 2.8.2) 137 | racc (~> 1.4) 138 | psych (5.2.2) 139 | date 140 | stringio 141 | racc (1.8.1) 142 | rack (3.1.8) 143 | rack-session (2.0.0) 144 | rack (>= 3.0.0) 145 | rack-test (2.2.0) 146 | rack (>= 1.3) 147 | rackup (2.2.1) 148 | rack (>= 3) 149 | rails (8.0.1) 150 | actioncable (= 8.0.1) 151 | actionmailbox (= 8.0.1) 152 | actionmailer (= 8.0.1) 153 | actionpack (= 8.0.1) 154 | actiontext (= 8.0.1) 155 | actionview (= 8.0.1) 156 | activejob (= 8.0.1) 157 | activemodel (= 8.0.1) 158 | activerecord (= 8.0.1) 159 | activestorage (= 8.0.1) 160 | activesupport (= 8.0.1) 161 | bundler (>= 1.15.0) 162 | railties (= 8.0.1) 163 | rails-dom-testing (2.2.0) 164 | activesupport (>= 5.0.0) 165 | minitest 166 | nokogiri (>= 1.6) 167 | rails-html-sanitizer (1.6.2) 168 | loofah (~> 2.21) 169 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 170 | railties (8.0.1) 171 | actionpack (= 8.0.1) 172 | activesupport (= 8.0.1) 173 | irb (~> 1.13) 174 | rackup (>= 1.0.0) 175 | rake (>= 12.2) 176 | thor (~> 1.0, >= 1.2.2) 177 | zeitwerk (~> 2.6) 178 | rake (13.2.1) 179 | rbtree (0.4.6) 180 | rdoc (6.10.0) 181 | psych (>= 4.0.0) 182 | redcarpet (3.6.0) 183 | reline (0.6.0) 184 | io-console (~> 0.5) 185 | rspec (3.13.0) 186 | rspec-core (~> 3.13.0) 187 | rspec-expectations (~> 3.13.0) 188 | rspec-mocks (~> 3.13.0) 189 | rspec-core (3.13.2) 190 | rspec-support (~> 3.13.0) 191 | rspec-expectations (3.13.3) 192 | diff-lcs (>= 1.2.0, < 2.0) 193 | rspec-support (~> 3.13.0) 194 | rspec-mocks (3.13.2) 195 | diff-lcs (>= 1.2.0, < 2.0) 196 | rspec-support (~> 3.13.0) 197 | rspec-rails (7.1.0) 198 | actionpack (>= 7.0) 199 | activesupport (>= 7.0) 200 | railties (>= 7.0) 201 | rspec-core (~> 3.13) 202 | rspec-expectations (~> 3.13) 203 | rspec-mocks (~> 3.13) 204 | rspec-support (~> 3.13) 205 | rspec-support (3.13.2) 206 | securerandom (0.4.1) 207 | set (1.1.1) 208 | sorted_set (1.0.3) 209 | rbtree 210 | set (~> 1.0) 211 | stackprof (0.2.26) 212 | stringio (3.1.2) 213 | thor (1.3.2) 214 | timeout (0.4.3) 215 | tomparse (0.4.2) 216 | tzinfo (2.0.6) 217 | concurrent-ruby (~> 1.0) 218 | uri (1.0.2) 219 | useragent (0.16.11) 220 | websocket-driver (0.7.6) 221 | websocket-extensions (>= 0.1.0) 222 | websocket-extensions (0.1.5) 223 | yard (0.9.37) 224 | yard-tomdoc (0.7.1) 225 | tomparse (>= 0.4.0) 226 | yard 227 | zeitwerk (2.7.1) 228 | 229 | PLATFORMS 230 | ruby 231 | 232 | DEPENDENCIES 233 | benchmark-ips 234 | curly-templates! 235 | genspec! 236 | github-markup 237 | rails (~> 8.0.0) 238 | rake 239 | redcarpet 240 | rspec (>= 3) 241 | rspec-rails 242 | stackprof 243 | yard 244 | yard-tomdoc 245 | 246 | BUNDLED WITH 247 | 2.5.17 248 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/zendesk/genspec.git 3 | revision: 76116991caf40ef940076f702f70a141ced84ce2 4 | branch: rails-7 5 | specs: 6 | genspec (0.3.2) 7 | rspec (>= 2, < 4) 8 | thor 9 | 10 | PATH 11 | remote: .. 12 | specs: 13 | curly-templates (3.4.0) 14 | actionpack (>= 6.1) 15 | sorted_set 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | actioncable (7.2.2.1) 21 | actionpack (= 7.2.2.1) 22 | activesupport (= 7.2.2.1) 23 | nio4r (~> 2.0) 24 | websocket-driver (>= 0.6.1) 25 | zeitwerk (~> 2.6) 26 | actionmailbox (7.2.2.1) 27 | actionpack (= 7.2.2.1) 28 | activejob (= 7.2.2.1) 29 | activerecord (= 7.2.2.1) 30 | activestorage (= 7.2.2.1) 31 | activesupport (= 7.2.2.1) 32 | mail (>= 2.8.0) 33 | actionmailer (7.2.2.1) 34 | actionpack (= 7.2.2.1) 35 | actionview (= 7.2.2.1) 36 | activejob (= 7.2.2.1) 37 | activesupport (= 7.2.2.1) 38 | mail (>= 2.8.0) 39 | rails-dom-testing (~> 2.2) 40 | actionpack (7.2.2.1) 41 | actionview (= 7.2.2.1) 42 | activesupport (= 7.2.2.1) 43 | nokogiri (>= 1.8.5) 44 | racc 45 | rack (>= 2.2.4, < 3.2) 46 | rack-session (>= 1.0.1) 47 | rack-test (>= 0.6.3) 48 | rails-dom-testing (~> 2.2) 49 | rails-html-sanitizer (~> 1.6) 50 | useragent (~> 0.16) 51 | actiontext (7.2.2.1) 52 | actionpack (= 7.2.2.1) 53 | activerecord (= 7.2.2.1) 54 | activestorage (= 7.2.2.1) 55 | activesupport (= 7.2.2.1) 56 | globalid (>= 0.6.0) 57 | nokogiri (>= 1.8.5) 58 | actionview (7.2.2.1) 59 | activesupport (= 7.2.2.1) 60 | builder (~> 3.1) 61 | erubi (~> 1.11) 62 | rails-dom-testing (~> 2.2) 63 | rails-html-sanitizer (~> 1.6) 64 | activejob (7.2.2.1) 65 | activesupport (= 7.2.2.1) 66 | globalid (>= 0.3.6) 67 | activemodel (7.2.2.1) 68 | activesupport (= 7.2.2.1) 69 | activerecord (7.2.2.1) 70 | activemodel (= 7.2.2.1) 71 | activesupport (= 7.2.2.1) 72 | timeout (>= 0.4.0) 73 | activestorage (7.2.2.1) 74 | actionpack (= 7.2.2.1) 75 | activejob (= 7.2.2.1) 76 | activerecord (= 7.2.2.1) 77 | activesupport (= 7.2.2.1) 78 | marcel (~> 1.0) 79 | activesupport (7.2.2.1) 80 | base64 81 | benchmark (>= 0.3) 82 | bigdecimal 83 | concurrent-ruby (~> 1.0, >= 1.3.1) 84 | connection_pool (>= 2.2.5) 85 | drb 86 | i18n (>= 1.6, < 2) 87 | logger (>= 1.4.2) 88 | minitest (>= 5.1) 89 | securerandom (>= 0.3) 90 | tzinfo (~> 2.0, >= 2.0.5) 91 | base64 (0.2.0) 92 | benchmark (0.4.0) 93 | benchmark-ips (2.14.0) 94 | bigdecimal (3.1.9) 95 | builder (3.3.0) 96 | concurrent-ruby (1.3.4) 97 | connection_pool (2.4.1) 98 | crass (1.0.6) 99 | date (3.4.1) 100 | diff-lcs (1.5.1) 101 | drb (2.2.1) 102 | erubi (1.13.1) 103 | github-markup (5.0.1) 104 | globalid (1.2.1) 105 | activesupport (>= 6.1) 106 | i18n (1.14.6) 107 | concurrent-ruby (~> 1.0) 108 | io-console (0.8.0) 109 | irb (1.14.3) 110 | rdoc (>= 4.0.0) 111 | reline (>= 0.4.2) 112 | logger (1.6.4) 113 | loofah (2.23.1) 114 | crass (~> 1.0.2) 115 | nokogiri (>= 1.12.0) 116 | mail (2.8.1) 117 | mini_mime (>= 0.1.1) 118 | net-imap 119 | net-pop 120 | net-smtp 121 | marcel (1.0.4) 122 | mini_mime (1.1.5) 123 | mini_portile2 (2.8.8) 124 | minitest (5.25.4) 125 | net-imap (0.5.4) 126 | date 127 | net-protocol 128 | net-pop (0.1.2) 129 | net-protocol 130 | net-protocol (0.2.2) 131 | timeout 132 | net-smtp (0.5.0) 133 | net-protocol 134 | nio4r (2.7.4) 135 | nokogiri (1.18.1) 136 | mini_portile2 (~> 2.8.2) 137 | racc (~> 1.4) 138 | psych (5.2.2) 139 | date 140 | stringio 141 | racc (1.8.1) 142 | rack (3.1.8) 143 | rack-session (2.0.0) 144 | rack (>= 3.0.0) 145 | rack-test (2.2.0) 146 | rack (>= 1.3) 147 | rackup (2.2.1) 148 | rack (>= 3) 149 | rails (7.2.2.1) 150 | actioncable (= 7.2.2.1) 151 | actionmailbox (= 7.2.2.1) 152 | actionmailer (= 7.2.2.1) 153 | actionpack (= 7.2.2.1) 154 | actiontext (= 7.2.2.1) 155 | actionview (= 7.2.2.1) 156 | activejob (= 7.2.2.1) 157 | activemodel (= 7.2.2.1) 158 | activerecord (= 7.2.2.1) 159 | activestorage (= 7.2.2.1) 160 | activesupport (= 7.2.2.1) 161 | bundler (>= 1.15.0) 162 | railties (= 7.2.2.1) 163 | rails-dom-testing (2.2.0) 164 | activesupport (>= 5.0.0) 165 | minitest 166 | nokogiri (>= 1.6) 167 | rails-html-sanitizer (1.6.2) 168 | loofah (~> 2.21) 169 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 170 | railties (7.2.2.1) 171 | actionpack (= 7.2.2.1) 172 | activesupport (= 7.2.2.1) 173 | irb (~> 1.13) 174 | rackup (>= 1.0.0) 175 | rake (>= 12.2) 176 | thor (~> 1.0, >= 1.2.2) 177 | zeitwerk (~> 2.6) 178 | rake (13.2.1) 179 | rbtree (0.4.6) 180 | rdoc (6.10.0) 181 | psych (>= 4.0.0) 182 | redcarpet (3.6.0) 183 | reline (0.6.0) 184 | io-console (~> 0.5) 185 | rspec (3.13.0) 186 | rspec-core (~> 3.13.0) 187 | rspec-expectations (~> 3.13.0) 188 | rspec-mocks (~> 3.13.0) 189 | rspec-core (3.13.2) 190 | rspec-support (~> 3.13.0) 191 | rspec-expectations (3.13.3) 192 | diff-lcs (>= 1.2.0, < 2.0) 193 | rspec-support (~> 3.13.0) 194 | rspec-mocks (3.13.2) 195 | diff-lcs (>= 1.2.0, < 2.0) 196 | rspec-support (~> 3.13.0) 197 | rspec-rails (7.1.0) 198 | actionpack (>= 7.0) 199 | activesupport (>= 7.0) 200 | railties (>= 7.0) 201 | rspec-core (~> 3.13) 202 | rspec-expectations (~> 3.13) 203 | rspec-mocks (~> 3.13) 204 | rspec-support (~> 3.13) 205 | rspec-support (3.13.2) 206 | securerandom (0.4.1) 207 | set (1.1.1) 208 | sorted_set (1.0.3) 209 | rbtree 210 | set (~> 1.0) 211 | stackprof (0.2.26) 212 | stringio (3.1.2) 213 | thor (1.3.2) 214 | timeout (0.4.3) 215 | tomparse (0.4.2) 216 | tzinfo (2.0.6) 217 | concurrent-ruby (~> 1.0) 218 | useragent (0.16.11) 219 | websocket-driver (0.7.6) 220 | websocket-extensions (>= 0.1.0) 221 | websocket-extensions (0.1.5) 222 | yard (0.9.37) 223 | yard-tomdoc (0.7.1) 224 | tomparse (>= 0.4.0) 225 | yard 226 | zeitwerk (2.7.1) 227 | 228 | PLATFORMS 229 | ruby 230 | 231 | DEPENDENCIES 232 | benchmark-ips 233 | curly-templates! 234 | genspec! 235 | github-markup 236 | rails (~> 7.2.0) 237 | rake 238 | redcarpet 239 | rspec (>= 3) 240 | rspec-rails 241 | stackprof 242 | yard 243 | yard-tomdoc 244 | 245 | BUNDLED WITH 246 | 2.5.17 247 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Unreleased 2 | * Add tests with Rails 7.2 and 8.0 3 | * Add tests with Ruby 3.4 4 | * Drop support for Ruby < 3.2 5 | 6 | ### Curly 3.4.0 (July 2, 2024) 7 | * Drop upper limit on Rails, test with Rails main. 8 | * Drop support for Ruby < 3.1. 9 | * Drop support for Rails < 6.1. 10 | 11 | ### Curly 3.3.0 (November 13, 2023) 12 | * Add support for Rails 7.1 13 | 14 | ### Curly 3.2.0 (June 1, 2023) 15 | 16 | * Add support for Ruby 3.2 17 | * Drop support for Ruby 2.6 18 | * Drop support for Rails 4.2 19 | 20 | ### Curly 3.1.0 (November, 21, 2022) 21 | 22 | * Add support for Ruby 3.0 & 3.1 23 | * Add support for Rails 7.0 24 | 25 | ### Curly 3.0.0 (January 19, 2021) 26 | 27 | * Add support for Rails 6.0 and 6.1. 28 | * Remove support for Rails versions below 4.2. 29 | 30 | ### Curly 2.6.5 (May 23, 2018) 31 | 32 | * Add support for Rails 5.2. 33 | 34 | *Benjamin Quorning* 35 | 36 | ### Curly 2.6.4 (April 28, 2017) 37 | 38 | * Add support for Rails 5.1. 39 | 40 | *Benjamin Quorning* 41 | 42 | ### Curly 2.6.3 (March 24, 2017) 43 | 44 | * Added generator for Rails' built in scaffold command. 45 | * Added `curly:install` generator for creating layout files. 46 | 47 | *Jack M* 48 | 49 | ### Curly 2.6.2 (December 22, 2016) 50 | 51 | * Change `DependencyTracker.call` to returns array, for compatibility with 52 | Rails 5.0. 53 | 54 | *Benjamin Quorning* 55 | 56 | ### Curly 2.6.1 (August 3, 2016) 57 | 58 | * Use Rails' `constantize` method instead of `get_const` when looking up 59 | presenter classes, so that Rails has a chance to autoload missing classes. 60 | 61 | *Creighton Long* 62 | 63 | ### Curly 2.6.0 (July 4, 2016) 64 | 65 | * Add support for Rails 5. 66 | 67 | * Add support for arbitrary component attributes. If the presenter method accepts 68 | arbitrary keyword arguments, the corresponding component is allowed to pass 69 | any attribute it wants. 70 | 71 | *Jeremy Rodi* 72 | 73 | * Add support for testing presenters with RSpec: 74 | 75 | ```ruby\ 76 | require 'curly/rspec' 77 | 78 | # spec/presenters/posts/show_presenter_spec.rb 79 | describe Posts::ShowPresenter, type: :presenter do 80 | describe "#body" do 81 | it "renders the post's body as Markdown" do 82 | assign(:post, double(:post, body: "**hello!**")) 83 | expect(presenter.body).to eq "hello!" 84 | end 85 | end 86 | end 87 | ``` 88 | 89 | *Daniel Schierbeck* 90 | 91 | ### Curly 2.5.0 (May 19, 2015) 92 | 93 | * Allow passing a block as the `default:` option to `presents`. 94 | 95 | ```ruby 96 | class CommentPresenter < Curly::Presenter 97 | presents :comment 98 | presents(:author) { @comment.author } 99 | end 100 | ``` 101 | 102 | *Steven Davidovitz & Jeremy Rodi* 103 | 104 | ### Curly 2.4.0 (February 24, 2015) 105 | 106 | * Add an `exposes_helper` class methods to Curly::Presenter. This allows exposing 107 | helper methods as components. 108 | 109 | *Jeremy Rodi* 110 | 111 | * Add a shorthand syntax for using components within a context. This allows you 112 | to write `{{author:name}}` rather than `{{@author}}{{name}}{{/author}}`. 113 | 114 | *Daniel Schierbeck* 115 | 116 | ### Curly 2.3.2 (January 13, 2015) 117 | 118 | * Fix an issue that caused presenter parameters to get mixed up. 119 | 120 | *Cristian Planas* 121 | 122 | * Clean up the testing code. 123 | 124 | *Daniel Schierbeck* 125 | 126 | ### Curly 2.3.1 (January 7, 2015) 127 | 128 | * Fix an issue with nested context blocks. 129 | 130 | *Daniel Schierbeck* 131 | 132 | * Make `respond_to_missing?` work with presenter objects. 133 | 134 | *Jeremy Rodi* 135 | 136 | ### Curly 2.3.0 (December 22, 2014) 137 | 138 | * Add support for Rails 4.2. 139 | 140 | *Łukasz Niemier* 141 | 142 | * Allow spaces within components. 143 | 144 | *Łukasz Niemier* 145 | 146 | ### Curly 2.2.0 (December 4, 2014) 147 | 148 | * Allow configuring arbitrary cache options. 149 | 150 | *Daniel Schierbeck* 151 | 152 | ### Curly 2.1.1 (November 12, 2014) 153 | 154 | * Fix a bug where a parent presenter's parameters were not being passed to the 155 | child presenter when using context blocks. 156 | 157 | *Daniel Schierbeck* 158 | 159 | ### Curly 2.1.0 (November 6, 2014) 160 | 161 | * Add support for [context blocks](https://github.com/zendesk/curly#context-blocks). 162 | 163 | *Daniel Schierbeck* 164 | 165 | * Forward the parent presenter's parameters to the nested presenter when 166 | rendering collection blocks. 167 | 168 | *Daniel Schierbeck* 169 | 170 | ### Curly 2.0.1 (September 9, 2014) 171 | 172 | * Fixed an issue when using Curly with Rails 4.1. 173 | 174 | *Daniel Schierbeck* 175 | 176 | * Add line number information to syntax errors. 177 | 178 | *Jeremy Rodi* 179 | 180 | ### Curly 2.0.0 (July 1, 2014) 181 | 182 | * Rename Curly::CompilationError to Curly::PresenterNotFound. 183 | 184 | *Daniel Schierbeck* 185 | 186 | ### Curly 2.0.0.beta1 (June 27, 2014) 187 | 188 | * Add support for collection blocks. 189 | 190 | *Daniel Schierbeck* 191 | 192 | * Add support for keyword parameters to references. 193 | 194 | *Alisson Cavalcante Agiani, Jeremy Rodi, and Daniel Schierbeck* 195 | 196 | * Remove memory leak that could cause unbounded memory growth. 197 | 198 | *Daniel Schierbeck* 199 | 200 | ### Curly 1.0.0rc1 (February 18, 2014) 201 | 202 | * Add support for conditional blocks: 203 | 204 | ``` 205 | {{#admin?}} 206 | Hello! 207 | {{/admin?}} 208 | ``` 209 | 210 | *Jeremy Rodi* 211 | 212 | ### Curly 0.12.0 (December 3, 2013) 213 | 214 | * Allow Curly to output Curly syntax by using the `{{{ ... }}` syntax: 215 | 216 | ``` 217 | {{{curly_example}} 218 | ``` 219 | 220 | *Daniel Schierbeck and Benjamin Quorning* 221 | 222 | ### Curly 0.11.0 (July 31, 2013) 223 | 224 | * Make Curly raise an exception when a reference or comment is not closed. 225 | 226 | *Daniel Schierbeck* 227 | 228 | * Fix a bug that caused an infinite loop when there was whitespace in a reference. 229 | 230 | *Daniel Schierbeck* 231 | 232 | ### Curly 0.10.2 (July 11, 2013) 233 | 234 | * Fix a bug that caused non-string presenter method return values to be 235 | discarded. 236 | 237 | *Daniel Schierbeck* 238 | 239 | ### Curly 0.10.1 (July 11, 2013) 240 | 241 | * Fix a bug in the compiler that caused some templates to be erroneously HTML 242 | escaped. 243 | 244 | *Daniel Schierbeck* 245 | 246 | ### Curly 0.10.0 (July 11, 2013) 247 | 248 | * Allow comments in Curly templates using the `{{! ... }}` syntax: 249 | 250 | ``` 251 | {{! This is a comment }} 252 | ``` 253 | 254 | *Daniel Schierbeck* 255 | 256 | ### Curly 0.9.1 (June 20, 2013) 257 | 258 | * Better error handling. If a presenter class cannot be found, we not raise a 259 | more descriptive exception. 260 | 261 | *Daniel Schierbeck* 262 | 263 | * Include the superclass' dependencies in a presenter's dependency list. 264 | 265 | *Daniel Schierbeck* 266 | 267 | ### Curly 0.9.0 (June 4, 2013) 268 | 269 | * Allow running setup code before rendering a Curly view. Simply add a `#setup!` 270 | method to your presenter – it will be called by Curly just before the view is 271 | rendered. 272 | 273 | *Daniel Schierbeck* 274 | -------------------------------------------------------------------------------- /spec/compiler/collections_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Compiler do 2 | include CompilationSupport 3 | 4 | context "normal rendering" do 5 | before do 6 | define_presenter "ItemPresenter" do 7 | presents :item 8 | delegate :name, to: :@item 9 | end 10 | end 11 | 12 | it "compiles collection blocks" do 13 | define_presenter do 14 | presents :items 15 | attr_reader :items 16 | end 17 | 18 | item1 = double("item1", name: "foo") 19 | item2 = double("item2", name: "bar") 20 | 21 | template = "
      {{*items}}
    • {{name}}
    • {{/items}}
    " 22 | expect(render(template, locals: { items: [item1, item2] })). 23 | to eql "
    • foo
    • bar
    " 24 | end 25 | 26 | it "allows attributes on collection blocks" do 27 | define_presenter do 28 | presents :items 29 | 30 | def items(status: nil) 31 | if status 32 | @items.select {|item| item.status == status } 33 | else 34 | @items 35 | end 36 | end 37 | end 38 | 39 | item1 = double("item1", name: "foo", status: "active") 40 | item2 = double("item2", name: "bar", status: "inactive") 41 | 42 | template = "
      {{*items status=active}}
    • {{name}}
    • {{/items}}
    " 43 | expect(render(template, locals: { items: [item1, item2] })). 44 | to eql "
    • foo
    " 45 | end 46 | 47 | it "fails if the component doesn't support enumeration" do 48 | template = "
      {{*numbers}}
    • {{name}}
    • {{/numbers}}
    " 49 | expect { render(template) }.to raise_exception(Curly::Error) 50 | end 51 | 52 | it "works even if the component method doesn't return an Array" do 53 | define_presenter do 54 | def companies 55 | "Arla" 56 | end 57 | end 58 | 59 | define_presenter "CompanyPresenter" do 60 | presents :company 61 | 62 | def name 63 | @company 64 | end 65 | end 66 | 67 | template = "
      {{*companies}}
    • {{name}}
    • {{/companies}}
    " 68 | expect(render(template)).to eql "
    • Arla
    " 69 | end 70 | 71 | it "passes the index of the current item to the nested presenter" do 72 | define_presenter do 73 | presents :items 74 | attr_reader :items 75 | end 76 | 77 | define_presenter "ItemPresenter" do 78 | presents :item_counter 79 | 80 | def index 81 | @item_counter 82 | end 83 | end 84 | 85 | item1 = double("item1") 86 | item2 = double("item2") 87 | 88 | template = "
      {{*items}}
    • {{index}}
    • {{/items}}
    " 89 | expect(render(template, locals: { items: [item1, item2] })). 90 | to eql "
    • 1
    • 2
    " 91 | end 92 | 93 | it "restores the previous scope after exiting the collection block" do 94 | define_presenter do 95 | presents :items 96 | attr_reader :items 97 | 98 | def title 99 | "Inventory" 100 | end 101 | end 102 | 103 | define_presenter "ItemPresenter" do 104 | presents :item 105 | delegate :name, :parts, to: :@item 106 | end 107 | 108 | define_presenter "PartPresenter" do 109 | presents :part 110 | delegate :identifier, to: :@part 111 | end 112 | 113 | part = double("part", identifier: "X") 114 | item = double("item", name: "foo", parts: [part]) 115 | 116 | template = "{{*items}}{{*parts}}{{identifier}}{{/parts}}{{name}}{{/items}}{{title}}" 117 | expect(render(template, locals: { items: [item] })). 118 | to eql "XfooInventory" 119 | end 120 | 121 | it "passes the parent presenter's options to the nested presenter" do 122 | define_presenter do 123 | presents :items, :prefix 124 | attr_reader :items 125 | end 126 | 127 | define_presenter "ItemPresenter" do 128 | presents :item, :prefix 129 | delegate :name, to: :@item 130 | attr_reader :prefix 131 | end 132 | 133 | item1 = double(name: "foo") 134 | item2 = double(name: "bar") 135 | 136 | template = "{{*items}}{{prefix}}: {{name}}; {{/items}}" 137 | expect(render(template, locals: { prefix: "SKU", items: [item1, item2] })). 138 | to eql "SKU: foo; SKU: bar; " 139 | end 140 | 141 | it "compiles nested collection blocks" do 142 | define_presenter do 143 | presents :items 144 | attr_reader :items 145 | end 146 | 147 | define_presenter "ItemPresenter" do 148 | presents :item 149 | delegate :name, :parts, to: :@item 150 | end 151 | 152 | define_presenter "PartPresenter" do 153 | presents :part 154 | delegate :identifier, to: :@part 155 | end 156 | 157 | item1 = double("item1", name: "item1", parts: [double(identifier: "A"), double(identifier: "B")]) 158 | item2 = double("item2", name: "item2", parts: [double(identifier: "C"), double(identifier: "D")]) 159 | 160 | template = "{{*items}}{{name}}: {{*parts}}{{identifier}}{{/parts}}; {{/items}}" 161 | expect(render(template, locals: { items: [item1, item2] })). 162 | to eql "item1: AB; item2: CD; " 163 | end 164 | end 165 | 166 | context "re-using assign names" do 167 | before do 168 | define_presenter do 169 | presents :comment 170 | 171 | attr_reader :comment 172 | 173 | def comments 174 | ["yolo", "xoxo"] 175 | end 176 | 177 | def comment(&block) 178 | block.call("foo!") 179 | end 180 | 181 | def form(&block) 182 | block.call 183 | end 184 | end 185 | 186 | define_presenter "CommentPresenter" do 187 | presents :comment 188 | end 189 | 190 | define_presenter "FormPresenter" do 191 | presents :comment 192 | attr_reader :comment 193 | end 194 | end 195 | 196 | it "allows re-using assign names in collection blocks" do 197 | options = { "comment" => "first post!" } 198 | template = "{{*comments}}{{/comments}}{{@form}}{{comment}}{{/form}}" 199 | expect(render(template, locals: options)).to eql "first post!" 200 | end 201 | 202 | it "allows re-using assign names in context blocks" do 203 | options = { "comment" => "first post!" } 204 | template = "{{@comment}}{{/comment}}{{@form}}{{comment}}{{/form}}" 205 | expect(render(template, locals: options)).to eql "first post!" 206 | end 207 | end 208 | 209 | context "using namespaced names" do 210 | before do 211 | define_presenter "Layouts::ShowPresenter" do 212 | def comments 213 | ["hello", "world"] 214 | end 215 | end 216 | 217 | define_presenter "Layouts::ShowPresenter::CommentPresenter" do 218 | presents :comment 219 | 220 | attr_reader :comment 221 | end 222 | end 223 | 224 | it "renders correctly" do 225 | template = "{{*comments}}{{comment}} {{/comments}}" 226 | expect(render(template, presenter: "Layouts::ShowPresenter")). 227 | to eql "hello world " 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /spec/presenter_spec.rb: -------------------------------------------------------------------------------- 1 | describe Curly::Presenter do 2 | class CircusPresenter < Curly::Presenter 3 | module MonkeyComponents 4 | def monkey 5 | end 6 | end 7 | 8 | exposes_helper :foo 9 | 10 | include MonkeyComponents 11 | 12 | presents :midget, :clown, default: nil 13 | presents :elephant, default: "Dumbo" 14 | presents :puma, default: -> { 'block' } 15 | presents(:lion) { @elephant.upcase } 16 | presents(:something) { self } 17 | 18 | attr_reader :midget, :clown, :elephant, :puma, :lion, :something 19 | end 20 | 21 | class FrenchCircusPresenter < CircusPresenter 22 | presents :elephant, default: "Babar" 23 | end 24 | 25 | class FancyCircusPresenter < CircusPresenter 26 | presents :champagne 27 | end 28 | 29 | class CircusPresenter::MonkeyPresenter < Curly::Presenter 30 | end 31 | 32 | module PresenterContainer 33 | class NestedPresenter < Curly::Presenter 34 | end 35 | module PresenterSubcontainer 36 | class SubNestedPresenter < Curly::Presenter 37 | end 38 | end 39 | end 40 | 41 | describe "#initialize" do 42 | let(:context) { double("context") } 43 | 44 | it "sets the presented identifiers as instance variables" do 45 | presenter = CircusPresenter.new(context, 46 | midget: "Meek Harolson", 47 | clown: "Bubbles" 48 | ) 49 | 50 | expect(presenter.midget).to eq "Meek Harolson" 51 | expect(presenter.clown).to eq "Bubbles" 52 | end 53 | 54 | it "raises an exception if a required identifier is not specified" do 55 | expect { 56 | FancyCircusPresenter.new(context, {}) 57 | }.to raise_exception(ArgumentError, "required identifier `champagne` missing") 58 | end 59 | 60 | it "allows specifying default values for identifiers" do 61 | # Make sure subclasses can change default values. 62 | french_presenter = FrenchCircusPresenter.new(context) 63 | expect(french_presenter.elephant).to eq "Babar" 64 | expect(french_presenter.lion).to eq 'BABAR' 65 | expect(french_presenter.puma).to be_a Proc 66 | 67 | # The subclass shouldn't change the superclass' defaults, though. 68 | presenter = CircusPresenter.new(context) 69 | expect(presenter.elephant).to eq "Dumbo" 70 | expect(presenter.lion).to eq 'DUMBO' 71 | expect(presenter.puma).to be_a Proc 72 | end 73 | 74 | it "doesn't call a block if given as a value for identifiers" do 75 | lion = proc { 'Simba' } 76 | presenter = CircusPresenter.new(context, lion: lion) 77 | expect(presenter.lion).to be lion 78 | end 79 | 80 | it "calls default blocks in the instance of the presenter" do 81 | presenter = CircusPresenter.new(context) 82 | expect(presenter.something).to be presenter 83 | end 84 | end 85 | 86 | describe "#method_missing" do 87 | let(:context) { double("context") } 88 | subject { 89 | CircusPresenter.new(context, 90 | midget: "Meek Harolson", 91 | clown: "Bubbles") 92 | } 93 | 94 | it "delegates calls to the context" do 95 | expect(context).to receive(:undefined).once 96 | subject.undefined 97 | end 98 | 99 | it "allows method calls on context-defined methods" do 100 | expect(context).to receive(:respond_to?). 101 | with(:undefined, false).once.and_return(true) 102 | subject.method(:undefined) 103 | end 104 | end 105 | 106 | describe ".exposes_helper" do 107 | let(:context) { double("context") } 108 | subject { 109 | CircusPresenter.new(context, 110 | midget: "Meek Harolson", 111 | clown: "Bubbles") 112 | } 113 | 114 | it "allows a method as a component" do 115 | CircusPresenter.component_available?(:foo) 116 | end 117 | 118 | it "delegates the call to the context" do 119 | expect(context).to receive(:foo).once 120 | expect(subject).not_to receive(:method_missing) 121 | subject.foo 122 | end 123 | 124 | it "doesn't delegate other calls to the context" do 125 | expect { subject.bar }.to raise_error RSpec::Mocks::MockExpectationError 126 | end 127 | end 128 | 129 | describe ".presenter_for_path" do 130 | it "returns the presenter class for the given path" do 131 | presenter = double("presenter") 132 | stub_const("Foo::BarPresenter", presenter) 133 | 134 | expect(Curly::Presenter.presenter_for_path("foo/bar")).to eq presenter 135 | end 136 | 137 | it "returns nil if there is no presenter for the given path" do 138 | expect(Curly::Presenter.presenter_for_path("foo/bar")).to be_nil 139 | end 140 | end 141 | 142 | describe ".presenter_for_name" do 143 | it 'looks through the container namespaces' do 144 | expect(PresenterContainer::PresenterSubcontainer::SubNestedPresenter.presenter_for_name('nested')).to eq PresenterContainer::NestedPresenter 145 | end 146 | 147 | it 'looks through the container namespaces' do 148 | expect(Curly::Presenter.presenter_for_name('presenter_container/presenter_subcontainer/nested', [])).to eq(PresenterContainer::NestedPresenter) 149 | end 150 | 151 | it "returns the presenter class for the given name" do 152 | expect(CircusPresenter.presenter_for_name("monkey")).to eq CircusPresenter::MonkeyPresenter 153 | end 154 | 155 | it "looks in the namespace" do 156 | expect(CircusPresenter.presenter_for_name("french_circus")).to eq FrenchCircusPresenter 157 | end 158 | 159 | it "returns Curly::PresenterNameError if the presenter class doesn't exist" do 160 | expect { CircusPresenter.presenter_for_name("clown") }.to raise_exception(Curly::PresenterNameError) 161 | end 162 | end 163 | 164 | describe ".available_components" do 165 | it "includes the methods on the presenter" do 166 | expect(CircusPresenter.available_components).to include("midget") 167 | end 168 | 169 | it "does not include methods on the Curly::Presenter base class" do 170 | expect(CircusPresenter.available_components).not_to include("cache_key") 171 | end 172 | end 173 | 174 | describe ".component_available?" do 175 | it "returns true if the method is available" do 176 | expect(CircusPresenter.component_available?("midget")).to eq true 177 | end 178 | 179 | it "returns false if the method is not available" do 180 | expect(CircusPresenter.component_available?("bear")).to eq false 181 | end 182 | end 183 | 184 | describe ".version" do 185 | it "sets the version of the presenter" do 186 | presenter1 = Class.new(Curly::Presenter) do 187 | version 42 188 | end 189 | 190 | presenter2 = Class.new(Curly::Presenter) do 191 | version 1337 192 | end 193 | 194 | expect(presenter1.version).to eq 42 195 | expect(presenter2.version).to eq 1337 196 | end 197 | 198 | it "returns 0 if no version has been set" do 199 | presenter = Class.new(Curly::Presenter) 200 | expect(presenter.version).to eq 0 201 | end 202 | end 203 | 204 | describe ".cache_key" do 205 | it "includes the presenter's class name and version" do 206 | presenter = Class.new(Curly::Presenter) { version 42 } 207 | stub_const("Foo::BarPresenter", presenter) 208 | 209 | expect(Foo::BarPresenter.cache_key).to eq "Foo::BarPresenter/42" 210 | end 211 | 212 | it "includes the cache keys of presenters in the dependency list" do 213 | presenter = Class.new(Curly::Presenter) do 214 | version 42 215 | depends_on 'foo/bum' 216 | end 217 | 218 | dependency = Class.new(Curly::Presenter) do 219 | version 1337 220 | end 221 | 222 | stub_const("Foo::BarPresenter", presenter) 223 | stub_const("Foo::BumPresenter", dependency) 224 | 225 | cache_key = Foo::BarPresenter.cache_key 226 | expect(cache_key).to eq "Foo::BarPresenter/42/Foo::BumPresenter/1337" 227 | end 228 | 229 | it "uses the view path of a dependency if there is no presenter for it" do 230 | presenter = Class.new(Curly::Presenter) do 231 | version 42 232 | depends_on 'foo/bum' 233 | end 234 | 235 | stub_const("Foo::BarPresenter", presenter) 236 | 237 | cache_key = Foo::BarPresenter.cache_key 238 | expect(cache_key).to eq "Foo::BarPresenter/42/foo/bum" 239 | end 240 | end 241 | 242 | describe ".dependencies" do 243 | it "returns the dependencies defined for the presenter" do 244 | presenter = Class.new(Curly::Presenter) { depends_on 'foo' } 245 | expect(presenter.dependencies.to_a).to eq ['foo'] 246 | end 247 | 248 | it "includes the dependencies defined for parent classes" do 249 | Curly::Presenter.dependencies 250 | parent = Class.new(Curly::Presenter) { depends_on 'foo' } 251 | presenter = Class.new(parent) { depends_on 'bar' } 252 | expect(presenter.dependencies.to_a).to match_array ['foo', 'bar'] 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/curly/presenter.rb: -------------------------------------------------------------------------------- 1 | require 'curly/presenter_name_error' 2 | require "sorted_set" 3 | 4 | module Curly 5 | 6 | # A base class that can be subclassed by concrete presenters. 7 | # 8 | # A Curly presenter is responsible for delivering data to templates, in the 9 | # form of simple strings. Each public instance method on the presenter class 10 | # can be referenced in a template. When a template is evaluated with a 11 | # presenter, the referenced methods will be called with no arguments, and 12 | # the returned strings inserted in place of the components in the template. 13 | # 14 | # Note that strings that are not HTML safe will be escaped. 15 | # 16 | # A presenter is always instantiated with a context to which it delegates 17 | # unknown messages, usually an instance of ActionView::Base provided by 18 | # Rails. See Curly::TemplateHandler for a typical use. 19 | # 20 | # Examples 21 | # 22 | # class BlogPresenter < Curly::Presenter 23 | # presents :post 24 | # 25 | # def title 26 | # @post.title 27 | # end 28 | # 29 | # def body 30 | # markdown(@post.body) 31 | # end 32 | # 33 | # def author 34 | # @post.author.full_name 35 | # end 36 | # end 37 | # 38 | # presenter = BlogPresenter.new(context, post: post) 39 | # presenter.author #=> "Jackie Chan" 40 | # 41 | class Presenter 42 | 43 | # Initializes the presenter with the given context and options. 44 | # 45 | # context - An ActionView::Base context. 46 | # options - A Hash of options given to the presenter. 47 | def initialize(context, options = {}) 48 | @_context = context 49 | options.stringify_keys! 50 | 51 | self.class.presented_names.each do |name| 52 | value = options.fetch(name) do 53 | default_values.fetch(name) do 54 | block = default_blocks.fetch(name) do 55 | raise ArgumentError.new("required identifier `#{name}` missing") 56 | end 57 | 58 | instance_exec(name, &block) 59 | end 60 | end 61 | 62 | instance_variable_set("@#{name}", value) 63 | end 64 | end 65 | 66 | # Sets up the view. 67 | # 68 | # Override this method in your presenter in order to do setup before the 69 | # template is rendered. One use case is to call `content_for` in order 70 | # to inject content into other templates, e.g. a layout. 71 | # 72 | # Examples 73 | # 74 | # class Posts::ShowPresenter < Curly::Presenter 75 | # presents :post 76 | # 77 | # def setup! 78 | # content_for :page_title, @post.title 79 | # end 80 | # end 81 | # 82 | # Returns nothing. 83 | def setup! 84 | # Does nothing. 85 | end 86 | 87 | # The key that should be used to cache the view. 88 | # 89 | # Unless `#cache_key` returns nil, the result of rendering the template 90 | # that the presenter supports will be cached. The return value will be 91 | # part of the final cache key, along with a digest of the template itself. 92 | # 93 | # Any object can be used as a cache key, so long as it 94 | # 95 | # - is a String, 96 | # - responds to #cache_key itself, or 97 | # - is an Array or a Hash whose items themselves fit either of these 98 | # criteria. 99 | # 100 | # Returns the cache key Object or nil if no caching should be performed. 101 | def cache_key 102 | nil 103 | end 104 | 105 | # The options that should be passed to the cache backend when caching the 106 | # view. The exact options may vary depending on the backend you're using. 107 | # 108 | # The most common option is `:expires_in`, which controls the duration of 109 | # time that the cached view should be considered fresh. Because it's so 110 | # common, you can set that option simply by defining `#cache_duration`. 111 | # 112 | # Note: if you set the `:expires_in` option through this method, the 113 | # `#cache_duration` value will be ignored. 114 | # 115 | # Returns a Hash. 116 | def cache_options 117 | {} 118 | end 119 | 120 | # The duration that the view should be cached for. Only relevant if 121 | # `#cache_key` returns a non nil value. 122 | # 123 | # If nil, the view will not have an expiration time set. See also 124 | # `#cache_options` for a more flexible way to set cache options. 125 | # 126 | # Examples 127 | # 128 | # def cache_duration 129 | # 10.minutes 130 | # end 131 | # 132 | # Returns the Fixnum duration of the cache item, in seconds, or nil if no 133 | # duration should be set. 134 | def cache_duration 135 | nil 136 | end 137 | 138 | class << self 139 | 140 | # The name of the presenter class for a given view path. 141 | # 142 | # path - The String path of a view. 143 | # 144 | # Examples 145 | # 146 | # Curly::TemplateHandler.presenter_name_for_path("foo/bar") 147 | # #=> "Foo::BarPresenter" 148 | # 149 | # Returns the String name of the matching presenter class. 150 | def presenter_name_for_path(path) 151 | "#{path}_presenter".camelize 152 | end 153 | 154 | # Returns the presenter class for the given path. 155 | # 156 | # path - The String path of a template. 157 | # 158 | # Returns the Class or nil if the constant cannot be found. 159 | def presenter_for_path(path) 160 | begin 161 | # Assume that the path can be derived without a prefix; In other words 162 | # from the given path we can look up objects by namespace. 163 | presenter_for_name(path.camelize, []) 164 | rescue Curly::PresenterNameError 165 | nil 166 | end 167 | end 168 | 169 | # Retrieve the named presenter with consideration for object scope. 170 | # The namespace_prefixes are to acknowledge that sometimes we will have 171 | # a subclass of Curly::Presenter receiving the .presenter_for_name 172 | # and other times we will not (when we are receiving this message by 173 | # way of the .presenter_for_path method). 174 | def presenter_for_name(name, namespace_prefixes = to_s.split('::')) 175 | full_class_name = name.camelcase << "Presenter" 176 | relative_namespace = full_class_name.split("::") 177 | class_name = relative_namespace.pop 178 | namespace = namespace_prefixes + relative_namespace 179 | 180 | # Because Rails' autoloading mechanism doesn't work properly with 181 | # namespace we need to loop through the namespace ourselves. Ideally, 182 | # `X::Y.const_get("Z")` would autoload `X::Z`, but only `X::Y::Z` is 183 | # attempted by Rails. This sucks, and hopefully we can find a better 184 | # solution in the future. 185 | begin 186 | full_name = namespace.join("::") << "::" << class_name 187 | full_name.constantize 188 | rescue NameError => e 189 | # Due to the way the exception hirearchy works, we need to check 190 | # that this exception is actually a `NameError` - since other 191 | # classes can inherit `NameError`, rescue will actually rescue 192 | # those classes as being under `NameError`, causing this block to 193 | # be executed for classes that aren't `NameError`s (but are rather 194 | # subclasses of it), which isn't the desired behavior. This 195 | # prevents anything but `NameError`s from triggering the resulting 196 | # code. `NoMethodError` is actually a subclass of `NameError`, 197 | # so a typo in a file (e.g. `present` instead of `presents`) can 198 | # cause the library to act as if the class was never defined. 199 | raise unless e.class == NameError 200 | if namespace.empty? 201 | raise Curly::PresenterNameError.new(e, name) 202 | end 203 | namespace.pop 204 | retry 205 | end 206 | end 207 | 208 | # Whether a component is available to templates rendered with the 209 | # presenter. 210 | # 211 | # Templates have components which correspond with methods defined on 212 | # the presenter. By default, only public instance methods can be 213 | # referenced, and any method defined on Curly::Presenter itself cannot be 214 | # referenced. This means that methods such as `#cache_key` and #inspect 215 | # are not available. This is done for safety purposes. 216 | # 217 | # This policy can be changed by overriding this method in your presenters. 218 | # 219 | # name - The String name of the component. 220 | # 221 | # Returns true if the method can be referenced by a template, 222 | # false otherwise. 223 | def component_available?(name) 224 | available_components.include?(name) 225 | end 226 | 227 | # A list of components available to templates rendered with the presenter. 228 | # 229 | # Returns an Array of String component names. 230 | def available_components 231 | @_available_components ||= begin 232 | methods = public_instance_methods - Curly::Presenter.public_instance_methods 233 | methods.map(&:to_s) 234 | end 235 | end 236 | 237 | # The set of view paths that the presenter depends on. 238 | # 239 | # Examples 240 | # 241 | # class Posts::ShowPresenter < Curly::Presenter 242 | # version 2 243 | # depends_on 'posts/comment', 'posts/comment_form' 244 | # end 245 | # 246 | # Posts::ShowPresenter.dependencies 247 | # #=> ['posts/comment', 'posts/comment_form'] 248 | # 249 | # Returns a Set of String view paths. 250 | def dependencies 251 | # The base presenter doesn't have any dependencies. 252 | return SortedSet.new if self == Curly::Presenter 253 | 254 | @dependencies ||= SortedSet.new 255 | @dependencies.union(superclass.dependencies) 256 | end 257 | 258 | # Indicate that the presenter depends a list of other views. 259 | # 260 | # deps - A list of String view paths that the presenter depends on. 261 | # 262 | # Returns nothing. 263 | def depends_on(*dependencies) 264 | @dependencies ||= SortedSet.new 265 | @dependencies.merge(dependencies) 266 | end 267 | 268 | # Get or set the version of the presenter. 269 | # 270 | # version - The Integer version that should be set. If nil, no version 271 | # is set. 272 | # 273 | # Returns the current Integer version of the presenter. 274 | def version(version = nil) 275 | @version = version if version.present? 276 | @version || 0 277 | end 278 | 279 | # The cache key for the presenter class. Includes all dependencies as 280 | # well. 281 | # 282 | # Returns a String cache key. 283 | def cache_key 284 | @cache_key ||= compute_cache_key 285 | end 286 | 287 | private 288 | 289 | def compute_cache_key 290 | dependency_cache_keys = dependencies.map do |path| 291 | if presenter = presenter_for_path(path) 292 | presenter.cache_key 293 | else 294 | path 295 | end 296 | end 297 | 298 | [name, version, dependency_cache_keys].flatten.join("/") 299 | end 300 | 301 | def presents(*args, **options, &block) 302 | if options.key?(:default) && block_given? 303 | raise ArgumentError, "Cannot provide both `default:` and block" 304 | end 305 | 306 | self.presented_names += args.map(&:to_s) 307 | 308 | if options.key?(:default) 309 | args.each do |arg| 310 | self.default_values = default_values.merge(arg.to_s => options[:default]).freeze 311 | end 312 | end 313 | 314 | if block_given? 315 | args.each do |arg| 316 | self.default_blocks = default_blocks.merge(arg.to_s => block).freeze 317 | end 318 | end 319 | end 320 | 321 | def exposes_helper(*methods) 322 | methods.each do |method_name| 323 | define_method(method_name) do |*args| 324 | @_context.public_send(method_name, *args) 325 | end 326 | end 327 | end 328 | 329 | alias_method :exposes_helpers, :exposes_helper 330 | end 331 | 332 | private 333 | 334 | class_attribute :presented_names, :default_values, :default_blocks 335 | 336 | self.presented_names = [].freeze 337 | self.default_values = {}.freeze 338 | self.default_blocks = {}.freeze 339 | 340 | delegate :render, to: :@_context 341 | 342 | # Delegates private method calls to the current view context. 343 | # 344 | # The view context, an instance of ActionView::Base, is set by Rails. 345 | def method_missing(method, *args, &block) 346 | @_context.public_send(method, *args, &block) 347 | end 348 | 349 | # Tells ruby (and developers) what methods we can accept. 350 | def respond_to_missing?(method, include_private = false) 351 | @_context.respond_to?(method, false) 352 | end 353 | end 354 | end 355 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Curly 2 | ======= 3 | 4 | Curly is a template language that completely separates structure and logic. 5 | Instead of interspersing your HTML with snippets of Ruby, all logic is moved 6 | to a presenter class. 7 | 8 | 9 | ### Table of Contents 10 | 11 | 1. [Installing](#installing) 12 | 2. [How to use Curly](#how-to-use-curly) 13 | 1. [Identifiers](#identifiers) 14 | 2. [Attributes](#attributes) 15 | 3. [Conditional blocks](#conditional-blocks) 16 | 4. [Collection blocks](#collection-blocks) 17 | 5. [Context blocks](#context-blocks) 18 | 6. [Setting up state](#setting-up-state) 19 | 7. [Escaping Curly syntax](#escaping-curly-syntax) 20 | 8. [Comments](#comments) 21 | 3. [Presenters](#presenters) 22 | 1. [Layouts and content blocks](#layouts-and-content-blocks) 23 | 2. [Rails helper methods](#rails-helper-methods) 24 | 3. [Testing](#testing) 25 | 4. [Examples](#examples) 26 | 4. [Caching](#caching) 27 | 28 | 29 | Installing 30 | ---------- 31 | 32 | Installing Curly is as simple as running `gem install curly-templates`. If you're 33 | using Bundler to manage your dependencies, add this to your Gemfile 34 | 35 | ```ruby 36 | gem 'curly-templates' 37 | ``` 38 | 39 | Curly can also install an application layout file, replacing the .erb file commonly 40 | created by Rails. If you wish to use this, run the `curly:install` generator. 41 | 42 | ```sh 43 | $ rails generate curly:install 44 | ``` 45 | 46 | 47 | How to use Curly 48 | ---------------- 49 | 50 | In order to use Curly for a view or partial, use the suffix `.curly` instead of 51 | `.erb`, e.g. `app/views/posts/_comment.html.curly`. Curly will look for a 52 | corresponding presenter class named `Posts::CommentPresenter`. By convention, 53 | these are placed in `app/presenters/`, so in this case the presenter would 54 | reside in `app/presenters/posts/comment_presenter.rb`. Note that presenters 55 | for partials are not prepended with an underscore. 56 | 57 | Add some HTML to the partial template along with some Curly components: 58 | 59 | ```html 60 | 61 |
    62 |

    63 | {{author_link}} posted {{time_ago}} ago. 64 |

    65 | 66 | {{body}} 67 | 68 | {{#author?}} 69 |

    {{deletion_link}}

    70 | {{/author?}} 71 |
    72 | ``` 73 | 74 | The presenter will be responsible for providing the data for the components. Add 75 | the necessary Ruby code to the presenter: 76 | 77 | ```ruby 78 | # app/presenters/posts/comment_presenter.rb 79 | class Posts::CommentPresenter < Curly::Presenter 80 | presents :comment 81 | 82 | def body 83 | SafeMarkdown.render(@comment.body) 84 | end 85 | 86 | def author_link 87 | link_to @comment.author.name, @comment.author, rel: "author" 88 | end 89 | 90 | def deletion_link 91 | link_to "Delete", @comment, method: :delete 92 | end 93 | 94 | def time_ago 95 | time_ago_in_words(@comment.created_at) 96 | end 97 | 98 | def author? 99 | @comment.author == current_user 100 | end 101 | end 102 | ``` 103 | 104 | The partial can now be rendered like any other, e.g. by calling 105 | 106 | ```ruby 107 | render 'comment', comment: comment 108 | render comment 109 | render collection: post.comments 110 | ``` 111 | 112 | Curly _components_ are surrounded by curly brackets, e.g. `{{hello}}`. They always map to a 113 | public method on the presenter class, in this case `#hello`. Methods ending in a question mark 114 | can be used for [conditional blocks](#conditional-blocks), e.g. `{{#admin?}} ... {{/admin?}}`. 115 | 116 | ### Identifiers 117 | 118 | Curly components can specify an _identifier_ using the so-called dot notation: `{{x.y.z}}`. 119 | This can be very useful if the data you're accessing is hierarchical in nature. One common 120 | example is I18n: 121 | 122 | ```html 123 |

    {{i18n.homepage.header}}

    124 | ``` 125 | 126 | ```ruby 127 | # In the presenter, the identifier is passed as an argument to the method. The 128 | # argument will always be a String. 129 | def i18n(key) 130 | translate(key) 131 | end 132 | ``` 133 | 134 | The identifier is separated from the component name with a dot. If the presenter method 135 | has a default value for the argument, the identifier is optional – otherwise it's mandatory. 136 | 137 | 138 | ### Attributes 139 | 140 | In addition to [an identifier](#identifiers), Curly components can be annotated 141 | with *attributes*. These are key-value pairs that affect how a component is rendered. 142 | 143 | The syntax is reminiscent of HTML: 144 | 145 | ```html 146 |
    {{sidebar rows=3 width=200px title="I'm the sidebar!"}}
    147 | ``` 148 | 149 | The presenter method that implements the component must have a matching keyword argument: 150 | 151 | ```ruby 152 | def sidebar(rows: "1", width: "100px", title:); end 153 | ``` 154 | 155 | All argument values will be strings. A compilation error will be raised if 156 | 157 | - an attribute is used in a component without a matching keyword argument being present 158 | in the method definition; or 159 | - a required keyword argument in the method definition is not set as an attribute in the 160 | component. 161 | 162 | You can define default values using Ruby's own syntax. Additionally, if the presenter 163 | method accepts arbitrary keyword arguments using the `**doublesplat` syntax then all 164 | attributes will be valid for the component, e.g. 165 | 166 | ```ruby 167 | def greetings(**names) 168 | names.map {|name, greeting| "#{name}: #{greeting}!" }.join("\n") 169 | end 170 | ``` 171 | 172 | ```html 173 | {{greetings alice=hello bob=hi}} 174 | 175 | alice: hello! 176 | bob: hi! 177 | ``` 178 | 179 | Note that since keyword arguments in Ruby are represented as Symbol objects, which are 180 | not garbage collected in Ruby versions less than 2.2, accepting arbitrary attributes 181 | represents a security vulnerability if your application allows untrusted Curly templates 182 | to be rendered. Only use this feature with trusted templates if you're not on Ruby 2.2 183 | yet. 184 | 185 | 186 | ### Conditional blocks 187 | 188 | If there is some content you only want rendered under specific circumstances, you can 189 | use _conditional blocks_. The `{{#admin?}}...{{/admin?}}` syntax will only render the 190 | content of the block if the `admin?` method on the presenter returns true, while the 191 | `{{^admin?}}...{{/admin?}}` syntax will only render the content if it returns false. 192 | 193 | Both forms can have an identifier: `{{#locale.en?}}...{{/locale.en?}}` will only 194 | render the block if the `locale?` method on the presenter returns true given the 195 | argument `"en"`. Here's how to implement that method in the presenter: 196 | 197 | ```ruby 198 | class SomePresenter < Curly::Presenter 199 | # Allows rendering content only if the locale matches a specified identifier. 200 | def locale?(identifier) 201 | current_locale == identifier 202 | end 203 | end 204 | ``` 205 | 206 | Furthermore, attributes can be set on the block. These only need to be specified when 207 | opening the block, not when closing it: 208 | 209 | ```html 210 | {{#square? width=3 height=3}} 211 |

    It's square!

    212 | {{/square?}} 213 | ``` 214 | 215 | Attributes work the same way as they do for normal components. 216 | 217 | 218 | ### Collection blocks 219 | 220 | Sometimes you want to render one or more items within the current template, and splitting 221 | out a separate template and rendering that in the presenter is too much overhead. You can 222 | instead define the template that should be used to render the items inline in the current 223 | template using the _collection block syntax_. 224 | 225 | Collection blocks are opened using an asterisk: 226 | 227 | ```html 228 | {{*comments}} 229 |
  • {{body}} ({{author_name}})
  • 230 | {{/comments}} 231 | ``` 232 | 233 | The presenter will need to expose the method `#comments`, which should return a collection 234 | of objects: 235 | 236 | ```ruby 237 | class Posts::ShowPresenter < Curly::Presenter 238 | presents :post 239 | 240 | def comments 241 | @post.comments 242 | end 243 | end 244 | ``` 245 | 246 | The template within the collection block will be used to render each item, and it will 247 | be backed by a presenter named after the component – in this case, `comments`. The name 248 | will be singularized and Curly will try to find the presenter class in the following 249 | order: 250 | 251 | * `Posts::ShowPresenter::CommentPresenter` 252 | * `Posts::CommentPresenter` 253 | * `CommentPresenter` 254 | 255 | This allows you some flexibility with regards to how you want to organize these nested 256 | templates and presenters. 257 | 258 | Note that the nested template will *only* have access to the methods on the nested 259 | presenter, but all variables passed to the "parent" presenter will be forwarded to 260 | the nested presenter. In addition, the current item in the collection will be 261 | passed, as well as that item's index in the collection: 262 | 263 | ```ruby 264 | class Posts::CommentPresenter < Curly::Presenter 265 | presents :post, :comment, :comment_counter 266 | 267 | def number 268 | # `comment_counter` is automatically set to the item's index in the collection, 269 | # starting with 1. 270 | @comment_counter 271 | end 272 | 273 | def body 274 | @comment.body 275 | end 276 | 277 | def author_name 278 | @comment.author.name 279 | end 280 | end 281 | ``` 282 | 283 | Collection blocks are an alternative to splitting out a separate template and rendering 284 | that from the presenter – which solution is best depends on your use case. 285 | 286 | 287 | ### Context blocks 288 | 289 | While collection blocks allow you to define the template that should be used to render 290 | items in a collection right within the parent template, **context blocks** allow you 291 | to define the template for an arbitrary context. This is very powerful, and can be used 292 | to define widget-style components and helpers, and provide an easy way to work with 293 | structured data. Let's say you have a comment form on your page, and you'd rather keep 294 | the template inline. A simple template could look like: 295 | 296 | ```html 297 | 298 |

    {{title}}

    299 | {{body}} 300 | 301 | {{@comment_form}} 302 | Name: {{name_field}}
    303 | E-mail: {{email_field}}
    304 | {{comment_field}} 305 | 306 | {{submit_button}} 307 | {{/comment_form}} 308 | ``` 309 | 310 | Note that an `@` character is used to denote a context block. Like with 311 | [collection blocks](#collection-blocks), a separate presenter class is used within the 312 | block, and a simple convention is used to find it. The name of the context component 313 | (in this case, `comment_form`) will be camel cased, and the current presenter's namespace 314 | will be searched: 315 | 316 | ```ruby 317 | class PostPresenter < Curly::Presenter 318 | presents :post 319 | def title; @post.title; end 320 | def body; markdown(@post.body); end 321 | 322 | # A context block method *must* take a block argument. The return value 323 | # of the method will be used when rendering. Calling the block argument will 324 | # render the nested template. If you pass a value when calling the block 325 | # argument it will be passed to the presenter. 326 | def comment_form(&block) 327 | form_for(Comment.new, &block) 328 | end 329 | 330 | # The presenter name is automatically deduced. 331 | class CommentFormPresenter < Curly::Presenter 332 | # The value passed to the block argument will be passed in a parameter named 333 | # after the component. 334 | presents :comment_form 335 | 336 | # Any parameters passed to the parent presenter will be forwarded to this 337 | # presenter as well. 338 | presents :post 339 | 340 | def name_field 341 | @comment_form.text_field :name 342 | end 343 | 344 | # ... 345 | end 346 | end 347 | ``` 348 | 349 | Context blocks were designed to work well with Rails' helper methods such as `form_for` 350 | and `content_tag`, but you can also work directly with the block. For instance, if you 351 | want to directly control the value that is passed to the nested presenter, you can call 352 | the `call` method on the block yourself: 353 | 354 | ```ruby 355 | def author(&block) 356 | content_tag :div, class: "author" do 357 | # The return value of `call` will be the result of rendering the nested template 358 | # with the argument. You can post-process the string if you want. 359 | block.call(@post.author) 360 | end 361 | end 362 | ``` 363 | 364 | #### Context shorthand syntax 365 | 366 | If you find yourself opening a context block just in order to use a single component, 367 | e.g. `{{@author}}{{name}}{{/author}}`, you can use the _shorthand syntax_ instead: 368 | `{{author:name}}`. This works for all component types, e.g. 369 | 370 | ```html 371 | {{#author:admin?}} 372 |

    The author is an admin!

    373 | {{/author:admin?}} 374 | ``` 375 | 376 | The syntax works for nested contexts as well, e.g. `{{comment:author:name}}`. Any 377 | identifier and attributes are passed to the target component, which in this example 378 | would be `{{name}}`. 379 | 380 | 381 | ### Setting up state 382 | 383 | Although most code in Curly presenters should be free of side effects, sometimes side 384 | effects are required. One common example is defining content for a `content_for` block. 385 | 386 | If a Curly presenter class defines a `setup!` method, it will be called before the view 387 | is rendered: 388 | 389 | ```ruby 390 | class PostPresenter < Curly::Presenter 391 | presents :post 392 | 393 | def setup! 394 | content_for :title, post.title 395 | 396 | content_for :sidebar do 397 | render 'post_sidebar', post: post 398 | end 399 | end 400 | end 401 | ``` 402 | 403 | ### Escaping Curly syntax 404 | 405 | In order to have `{{` appear verbatim in the rendered HTML, use the triple Curly escape syntax: 406 | 407 | ``` 408 | This is {{{escaped}}. 409 | ``` 410 | 411 | You don't need to escape the closing `}}`. 412 | 413 | 414 | ### Comments 415 | 416 | If you want to add comments to your Curly templates that are not visible in the rendered HTML, 417 | use the following syntax: 418 | 419 | ```html 420 | {{! This is some interesting stuff }} 421 | ``` 422 | 423 | 424 | Presenters 425 | ---------- 426 | 427 | Presenters are classes that inherit from `Curly::Presenter` – they're usually placed in 428 | `app/presenters/`, but you can put them anywhere you'd like. The name of the presenter 429 | classes match the virtual path of the view they're part of, so if your controller is 430 | rendering `posts/show`, the `Posts::ShowPresenter` class will be used. Note that Curly 431 | is only used to render a view if a template can be found – in this case, at 432 | `app/views/posts/show.html.curly`. 433 | 434 | Presenters can declare a list of accepted variables using the `presents` method: 435 | 436 | ```ruby 437 | class Posts::ShowPresenter < Curly::Presenter 438 | presents :post 439 | end 440 | ``` 441 | 442 | A variable can have a default value: 443 | 444 | ```ruby 445 | class Posts::ShowPresenter < Curly::Presenter 446 | presents :post 447 | presents :comment, default: nil 448 | end 449 | ``` 450 | 451 | Any public method defined on the presenter is made available to the template as 452 | a component: 453 | 454 | ```ruby 455 | class Posts::ShowPresenter < Curly::Presenter 456 | presents :post 457 | 458 | def title 459 | @post.title 460 | end 461 | 462 | def author_link 463 | # You can call any Rails helper from within a presenter instance: 464 | link_to author.name, profile_path(author), rel: "author" 465 | end 466 | 467 | private 468 | 469 | # Private methods are not available to the template, so they're safe to 470 | # use. 471 | def author 472 | @post.author 473 | end 474 | end 475 | ``` 476 | 477 | Presenter methods can even take an argument. Say your Curly template has the content 478 | `{{t.welcome_message}}`, where `welcome_message` is an I18n key. The following presenter 479 | method would make the lookup work: 480 | 481 | ```ruby 482 | def t(key) 483 | translate(key) 484 | end 485 | ``` 486 | 487 | That way, simple ``functions'' can be added to the Curly language. Make sure these do not 488 | have any side effects, though, as an important part of Curly is the idempotence of the 489 | templates. 490 | 491 | 492 | ### Layouts and content blocks 493 | 494 | Both layouts and content blocks (see [`content_for`](http://api.rubyonrails.org/classes/ActionView/Helpers/CaptureHelper.html#method-i-content_for)) 495 | use `yield` to signal that content can be inserted. Curly works just like ERB, so calling 496 | `yield` with no arguments will make the view usable as a layout, while passing a Symbol 497 | will make it try to read a content block with the given name: 498 | 499 | ```ruby 500 | # Given you have the following Curly template in 501 | # app/views/layouts/application.html.curly 502 | # 503 | # 504 | # 505 | # {{title}} 506 | # 507 | # 508 | # 509 | # {{body}} 510 | # 511 | # 512 | # 513 | class ApplicationLayout < Curly::Presenter 514 | def title 515 | "You can use methods just like in any other presenter!" 516 | end 517 | 518 | def sidebar 519 | # A view can call `content_for(:sidebar) { "some HTML here" }` 520 | yield :sidebar 521 | end 522 | 523 | def body 524 | # The view will be rendered and inserted here: 525 | yield 526 | end 527 | end 528 | ``` 529 | 530 | 531 | ### Rails helper methods 532 | 533 | In order to make a Rails helper method available as a component in your template, 534 | use the `exposes_helper` method: 535 | 536 | ```ruby 537 | class Layouts::ApplicationPresenter < Curly::Presenter 538 | # The components {{sign_in_path}} and {{root_path}} are made available. 539 | exposes_helper :sign_in_path, :root_path 540 | end 541 | ``` 542 | 543 | 544 | ### Testing 545 | 546 | Presenters can be tested directly, but sometimes it makes sense to integrate with 547 | Rails on some levels. Currently, only RSpec is directly supported, but you can 548 | easily instantiate a presenter: 549 | 550 | ```ruby 551 | SomePresenter.new(context, assigns) 552 | ``` 553 | 554 | `context` is a view context, i.e. an object that responds to `render`, has all 555 | the helper methods you expect, etc. You can pass in a test double and see what 556 | you need to stub out. `assigns` is the hash containing the controller and local 557 | assigns. You need to pass in a key for each argument the presenter expects. 558 | 559 | #### Testing with RSpec 560 | 561 | In order to test presenters with RSpec, make sure you have `rspec-rails` in your 562 | Gemfile. Given the following presenter: 563 | 564 | ```ruby 565 | # app/presenters/posts/show_presenter.rb 566 | class Posts::ShowPresenter < Curly::Presenter 567 | presents :post 568 | 569 | def body 570 | Markdown.render(@post.body) 571 | end 572 | end 573 | ``` 574 | 575 | You can test the presenter methods like this: 576 | 577 | ```ruby 578 | # You can put this in your `spec_helper.rb`. 579 | require 'curly/rspec' 580 | 581 | # spec/presenters/posts/show_presenter_spec.rb 582 | describe Posts::ShowPresenter, type: :presenter do 583 | describe "#body" do 584 | it "renders the post's body as Markdown" do 585 | assign(:post, double(:post, body: "**hello!**")) 586 | expect(presenter.body).to eq "hello!" 587 | end 588 | end 589 | end 590 | ``` 591 | 592 | Note that your spec *must* be tagged with `type: :presenter`. 593 | 594 | 595 | ### Examples 596 | 597 | Here is a simple Curly template – it will be looked up by Rails automatically. 598 | 599 | ```html 600 | 601 |

    {{title}}

    602 |

    {{author}}

    603 |

    {{description}}

    604 | 605 | {{comment_form}} 606 | 607 |
    608 | {{comments}} 609 |
    610 | ``` 611 | 612 | When rendering the template, a presenter is automatically instantiated with the 613 | variables assigned in the controller or the `render` call. The presenter declares 614 | the variables it expects with `presents`, which takes a list of variables names. 615 | 616 | ```ruby 617 | # app/presenters/posts/show_presenter.rb 618 | class Posts::ShowPresenter < Curly::Presenter 619 | presents :post 620 | 621 | def title 622 | @post.title 623 | end 624 | 625 | def author 626 | link_to(@post.author.name, @post.author, rel: "author") 627 | end 628 | 629 | def description 630 | Markdown.new(@post.description).to_html.html_safe 631 | end 632 | 633 | def comments 634 | render 'comment', collection: @post.comments 635 | end 636 | 637 | def comment_form 638 | if @post.comments_allowed? 639 | render 'comment_form', post: @post 640 | else 641 | content_tag(:p, "Comments are disabled for this post") 642 | end 643 | end 644 | end 645 | ``` 646 | 647 | 648 | Caching 649 | ------- 650 | 651 | Caching is handled at two levels in Curly – statically and dynamically. Static caching 652 | concerns changes to your code and templates introduced by deploys. If you do not wish 653 | to clear your entire cache every time you deploy, you need a way to indicate that some 654 | view, helper, or other piece of logic has changed. 655 | 656 | Dynamic caching concerns changes that happen on the fly, usually made by your users in 657 | the running system. You wish to cache a view or a partial and have it expire whenever 658 | some data is updated – usually whenever a specific record is changed. 659 | 660 | 661 | ### Dynamic Caching 662 | 663 | Because of the way logic is contained in presenters, caching entire views or partials 664 | by the data they present becomes exceedingly straightforward. Simply define a 665 | `#cache_key` method that returns a non-nil object, and the return value will be used to 666 | cache the template. 667 | 668 | Whereas in ERB you would include the `cache` call in the template itself: 669 | 670 | ```erb 671 | <% cache([@post, signed_in?]) do %> 672 | ... 673 | <% end %> 674 | ``` 675 | 676 | In Curly you would instead declare it in the presenter: 677 | 678 | ```ruby 679 | class Posts::ShowPresenter < Curly::Presenter 680 | presents :post 681 | 682 | def cache_key 683 | [@post, signed_in?] 684 | end 685 | end 686 | ``` 687 | 688 | Likewise, you can add a `#cache_duration` method if you wish to automatically expire 689 | the fragment cache: 690 | 691 | ```ruby 692 | class Posts::ShowPresenter < Curly::Presenter 693 | ... 694 | 695 | def cache_duration 696 | 30.minutes 697 | end 698 | end 699 | ``` 700 | 701 | In order to set *any* cache option, define a `#cache_options` method that 702 | returns a Hash of options: 703 | 704 | ```ruby 705 | class Posts::ShowPresenter < Curly::Presenter 706 | ... 707 | 708 | def cache_options 709 | { compress: true, namespace: "my-app" } 710 | end 711 | end 712 | ``` 713 | 714 | 715 | ### Static Caching 716 | 717 | Static caching will only be enabled for presenters that define a non-nil `#cache_key` 718 | method (see [Dynamic Caching.](#dynamic-caching)) 719 | 720 | In order to make a deploy expire the cache for a specific view, set the `version` of the 721 | view to something new, usually by incrementing by one: 722 | 723 | ```ruby 724 | class Posts::ShowPresenter < Curly::Presenter 725 | version 3 726 | 727 | def cache_key 728 | # Some objects 729 | end 730 | end 731 | ``` 732 | 733 | This will change the cache keys for all instances of that view, effectively expiring 734 | the old cache entries. 735 | 736 | This works well for views, or for partials that are rendered in views that themselves 737 | are not cached. If the partial is nested within a view that _is_ cached, however, the 738 | outer cache will not be expired. The solution is to register that the inner partial 739 | is a dependency of the outer one such that Curly can automatically deduce that the 740 | outer partial cache should be expired: 741 | 742 | ```ruby 743 | class Posts::ShowPresenter < Curly::Presenter 744 | version 3 745 | depends_on 'posts/comment' 746 | 747 | def cache_key 748 | # Some objects 749 | end 750 | end 751 | 752 | class Posts::CommentPresenter < Curly::Presenter 753 | version 4 754 | 755 | def cache_key 756 | # Some objects 757 | end 758 | end 759 | ``` 760 | 761 | Now, if the `version` of `Posts::CommentPresenter` is bumped, the cache keys for both 762 | presenters would change. You can register any number of view paths with `depends_on`. 763 | 764 | Curly integrates well with the 765 | [caching mechanism](http://guides.rubyonrails.org/caching_with_rails.html) in Rails 4 (or 766 | [Cache Digests](https://github.com/rails/cache_digests) in Rails 3), so the dependencies 767 | defined with `depends_on` will be tracked by Rails. This will allow you to deploy changes 768 | to your templates and have the relevant caches automatically expire. 769 | 770 | 771 | Thanks 772 | ------ 773 | 774 | Thanks to [Zendesk](http://zendesk.com/) for sponsoring the work on Curly. 775 | 776 | 777 | ### Contributors 778 | 779 | - Daniel Schierbeck ([@dasch](https://github.com/dasch)) 780 | - Benjamin Quorning ([@bquorning](https://github.com/bquorning)) 781 | - Jeremy Rodi ([@medcat](https://github.com/medcat)) 782 | - Alisson Cavalcante Agiani ([@thelinuxlich](https://github.com/thelinuxlich)) 783 | - Łukasz Niemier ([@hauleth](https://github.com/hauleth)) 784 | - Cristian Planas ([@Gawyn](https://github.com/Gawyn)) 785 | - Steven Davidovitz ([@steved](https://github.com/steved)) 786 | 787 | 788 | Build Status 789 | ------------ 790 | 791 | [![Build Status](https://github.com/zendesk/curly/workflows/CI/badge.svg)](https://github.com/zendesk/curly/actions?query=workflow%3ACI) 792 | 793 | Copyright and License 794 | --------------------- 795 | 796 | Copyright (c) 2013 Daniel Schierbeck (@dasch), Zendesk Inc. 797 | 798 | Licensed under the [Apache License Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 799 | --------------------------------------------------------------------------------