├── .ruby-version ├── spec ├── dummy │ ├── log │ │ ├── .keep │ │ └── test.log │ ├── app │ │ ├── mailers │ │ │ └── .keep │ │ ├── models │ │ │ ├── .keep │ │ │ └── concerns │ │ │ │ └── .keep │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── stylesheets │ │ │ │ └── application.css │ │ │ └── javascripts │ │ │ │ └── application.js │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ └── views │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ └── rails │ ├── config.ru │ ├── config │ │ ├── initializers │ │ │ ├── session_store.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── mime_types.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── wrap_parameters.rb │ │ │ ├── secret_token.rb │ │ │ └── inflections.rb │ │ ├── environment.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── application.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ │ └── routes.rb │ ├── Rakefile │ └── README.rdoc ├── mustaches │ └── users │ │ └── simple_info.mustache ├── lib │ ├── perspectives_spec.rb │ └── perspectives │ │ ├── templating_spec.rb │ │ └── properties_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── lib ├── perspectives │ ├── version.rb │ ├── forms.rb │ ├── forms │ │ ├── base.rb │ │ └── text_field.rb │ ├── rendering.rb │ ├── active_record.rb │ ├── responder.rb │ ├── collection.rb │ ├── configuration.rb │ ├── context.rb │ ├── base.rb │ ├── mustache_compiler.rb │ ├── templating.rb │ ├── memoization.rb │ ├── params.rb │ ├── railtie.rb │ ├── caching.rb │ ├── properties.rb │ └── controller_additions.rb ├── tasks │ └── perspectives_tasks.rake ├── generators │ └── perspectives │ │ ├── scaffold │ │ ├── templates │ │ │ ├── new.mustache │ │ │ ├── edit.mustache │ │ │ ├── new.rb │ │ │ ├── show.mustache │ │ │ ├── edit.rb │ │ │ ├── index.rb │ │ │ ├── tiny.mustache │ │ │ ├── show.rb │ │ │ ├── tiny.rb │ │ │ ├── index.mustache │ │ │ ├── form.mustache │ │ │ └── form.rb │ │ └── scaffold_generator.rb │ │ ├── templates │ │ ├── application.js │ │ └── rails │ │ │ └── scaffold_controller │ │ │ └── controller.rb │ │ └── install.rb ├── rails │ └── projections.json └── perspectives.rb ├── vendor └── assets │ └── javascripts │ ├── perspectives_views.js │ ├── perspectives │ └── forms │ │ └── text_field.mustache │ ├── perspectives.js │ └── mustache-0.8.1.js ├── .gitignore ├── Rakefile ├── Gemfile ├── TODO.md ├── perspectives.gemspec ├── MIT-LICENSE ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1.1 2 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/log/test.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/mustaches/users/simple_info.mustache: -------------------------------------------------------------------------------- 1 | Simple info test 2 | -------------------------------------------------------------------------------- /lib/perspectives/version.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | VERSION = "0.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/perspectives_views.js: -------------------------------------------------------------------------------- 1 | //= require_tree ./perspectives/forms 2 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/lib/perspectives_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Perspectives do 4 | it { should_not be_nil } 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/perspectives_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :perspectives do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/new.mustache: -------------------------------------------------------------------------------- 1 |

New <%= singular_table_name %>

2 | 3 | {{{form}}} 4 | 5 | Back 6 | -------------------------------------------------------------------------------- /lib/perspectives/forms.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Forms 3 | end 4 | end 5 | 6 | require 'perspectives/forms/base' 7 | require 'perspectives/forms/text_field' 8 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/log/*.log 7 | test/dummy/tmp/ 8 | test/dummy/.sass-cache 9 | tags 10 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/edit.mustache: -------------------------------------------------------------------------------- 1 |

Editing <%= singular_table_name %>

2 | 3 | {{{form}}} 4 | 5 | Show 6 | Back 7 | -------------------------------------------------------------------------------- /lib/perspectives/forms/base.rb: -------------------------------------------------------------------------------- 1 | class Perspectives::Forms::Base < Perspectives::Base 2 | def self.template_path 3 | File.expand_path('../../../../vendor/assets/javascripts', __FILE__) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/perspectives/forms/text_field.mustache: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /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.exists?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 6 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Dummy::Application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/new.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::New < Perspectives::Base 2 | param :<%= singular_table_name %> 3 | 4 | property(:index_href) { <%= plural_table_name %>_path } 5 | 6 | nested 'form', <%= singular_table_name %>: :<%= singular_table_name %> 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Configure Rails Environment 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | Bundler.require(:test) 7 | 8 | require File.expand_path("../dummy/config/environment.rb", __FILE__) 9 | require "rails/test_help" 10 | 11 | Rails.backtrace_cleaner.remove_silencers! 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | Bundler::GemHelper.install_tasks 8 | 9 | require 'rspec/core/rake_task' 10 | 11 | RSpec::Core::RakeTask.new(:spec) 12 | 13 | task :default => :spec 14 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/show.mustache: -------------------------------------------------------------------------------- 1 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 2 |

3 | <%= attribute.human_name %>: 4 | {{<%= attribute.name %>}} 5 |

6 | 7 | <% end -%> 8 | 9 | Edit 10 | Back 11 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/edit.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::Edit < Perspectives::Base 2 | param :<%= singular_table_name %> 3 | 4 | property(:show_href) { <%= singular_table_name %>_path(<%= singular_table_name %>) } 5 | property(:index_href) { <%= plural_table_name %>_path } 6 | 7 | nested 'form', <%= singular_table_name %>: :<%= singular_table_name %> 8 | end 9 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/index.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::Index < Perspectives::Base 2 | param :all_<%= plural_table_name %> 3 | 4 | property(:new_href) { new_<%= singular_table_name %>_path } 5 | 6 | nested_collection '<%= plural_table_name %>/tiny', 7 | collection: proc { all_<%= plural_table_name %> }, 8 | property: :<%= plural_table_name %> 9 | end 10 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/tiny.mustache: -------------------------------------------------------------------------------- 1 | 2 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 3 | {{<%= attribute.name %>}} 4 | <% end %> 5 | Show 6 | Edit 7 | Destroy 8 | 9 | -------------------------------------------------------------------------------- /lib/rails/projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "config/projections.json": {"command": "projections"}, 3 | "config/application.rb": {"command": "application"}, 4 | "app/perspectives/*.rb": { 5 | "command": "perspective", 6 | "alternate": "app/mustaches/%s.mustache" 7 | }, 8 | "app/mustaches/*.mustache": { 9 | "command": "mview", 10 | "alternate": "app/perspectives/%s.rb" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag "application", media: "all", "data-turbolinks-track" => true %> 6 | <%= javascript_include_tag "application", "data-turbolinks-track" => true %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/perspectives/rendering.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Rendering 3 | def as_json(options = {}) 4 | _property_map.merge(_template_key: _template_key) 5 | end 6 | 7 | def render_html 8 | _mustache.render(_property_map).html_safe 9 | end 10 | 11 | def render; render_html; end 12 | def to_html; render_html; end 13 | def to_s; render_html; end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'perspectives' 2 | require 'ostruct' 3 | require 'pry' 4 | 5 | # Load support files 6 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 7 | 8 | RSpec.configure do |config| 9 | config.color_enabled = true 10 | end 11 | 12 | Perspectives.configure do |c| 13 | c.template_path = File.expand_path('../mustaches', __FILE__) 14 | end 15 | 16 | puts Perspectives.template_path 17 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/show.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::Show < Perspectives::Base 2 | param :<%= singular_table_name %> 3 | 4 | delegate_property <%= attributes.map { |a| ":#{a.name}" }.join(', ') %>, to: :<%= singular_table_name %> 5 | 6 | property(:edit_href) { edit_<%= singular_table_name %>_path(<%= singular_table_name %>) } 7 | property(:index_href) { <%= plural_table_name %>_path } 8 | end 9 | -------------------------------------------------------------------------------- /lib/perspectives/active_record.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module ActiveRecord 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | module ClassMethods 8 | def id_param 9 | :"#{active_record_klass.name.underscore}_id" 10 | end 11 | 12 | def active_record_klass 13 | name.split('::').first.singularize.constantize 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/perspectives/responder.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | class Responder < ActionController::Responder 3 | def to_html 4 | return super unless controller.__send__(:perspectives_enabled_action?) 5 | 6 | render text: resource.to_html, layout: :default 7 | end 8 | 9 | def to_json 10 | return super unless controller.__send__(:perspectives_enabled_action?) 11 | 12 | render json: resource 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/tiny.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::Tiny < Perspectives::Base 2 | param :<%= singular_table_name %> 3 | 4 | delegate_property <%= attributes.map { |a| ":#{a.name}" }.join(', ') %>, to: :<%= singular_table_name %> 5 | 6 | property(:show_href) { <%= singular_table_name %>_path(<%= singular_table_name %>) } 7 | property(:edit_href) { edit_<%= singular_table_name %>_path(<%= singular_table_name %>) } 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /lib/perspectives/collection.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | class Collection 3 | include ::Enumerable 4 | 5 | def initialize(perspectives) 6 | @perspectives = perspectives 7 | end 8 | 9 | def each(&block) 10 | perspectives.each(&block) 11 | end 12 | 13 | def to_html 14 | perspectives.map(&:to_html).join 15 | end 16 | alias_method :to_s, :to_html 17 | 18 | private 19 | attr_reader :perspectives 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/perspectives/configuration.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | class Configuration 3 | delegate :template_path, :template_path=, :raise_on_context_miss?, :raise_on_context_miss, :raise_on_context_miss=, to: 'Mustache' 4 | 5 | CacheNotConfigured = Class.new(StandardError) 6 | attr_writer :cache 7 | attr_accessor :caching 8 | alias_method :caching?, :caching 9 | 10 | def cache 11 | @cache || (raise CacheNotConfigured, "You must configure a cache") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/lib/perspectives/templating_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Perspectives::Templating do 4 | module ::Users 5 | class SimpleInfo < Perspectives::Base 6 | end 7 | end 8 | 9 | subject { ::Users::SimpleInfo } 10 | 11 | its(:_template_key) { should == 'users/simple_info' } 12 | 13 | context 'backing mustache template' do 14 | subject { ::Users::SimpleInfo._mustache } 15 | it { should_not be_nil } 16 | its(:render) { should == "Simple info test\n" } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/perspectives/context.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Context 3 | def respond_to?(method, include_private = false) 4 | super || context.key?(method) 5 | end 6 | 7 | private 8 | 9 | def method_missing(method, *args, &block) 10 | if args.empty? && !block_given? && context.key?(method) 11 | self.class.__send__(:define_method, method) { __send__(:context).fetch(method) } 12 | __send__(method) 13 | else 14 | super 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/index.mustache: -------------------------------------------------------------------------------- 1 |

Listing <%= plural_table_name %>

2 | 3 | 4 | 5 | 6 | <% attributes.reject(&:password_digest?).each do |attribute| -%> 7 | 8 | <% end -%> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{{<%= plural_table_name %>}}} 17 | 18 |
<%= attribute.human_name %>
19 | 20 |
21 | 22 | New <%= singular_table_name %> 23 | -------------------------------------------------------------------------------- /lib/perspectives/forms/text_field.rb: -------------------------------------------------------------------------------- 1 | module Perspectives::Forms 2 | class TextField < Base 3 | param :object, :field 4 | 5 | property(:param_key) { object.class.model_name.param_key } 6 | property(:human_name) { object.class.name.humanize } 7 | property(:field_id) { "#{param_key}_#{field}" } 8 | property(:field_param) do 9 | "#{param_key}[#{field.sub(/\?$/, '')}]" 10 | end 11 | 12 | property(:name) { object.class.human_attribute_name(field) } 13 | property(:value) { object.__send__(field) } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Declare your gem's dependencies in perspectives.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | 13 | # To use debugger 14 | # gem 'debugger' 15 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require_self 12 | *= require_tree . 13 | */ 14 | -------------------------------------------------------------------------------- /lib/generators/perspectives/templates/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into including all the files listed below. 2 | // Add new JavaScript/Coffee code in separate files in this directory and they'll automatically 3 | // be included in the compiled file accessible from http://example.com/assets/application.js 4 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 5 | // the compiled file. 6 | // 7 | //= require mustache-0.8.1 8 | //= require perspectives 9 | //= require_tree ../../mustaches 10 | //= require perspectives_views 11 | //= require_tree . 12 | 13 | $(function() { $(document).perspectives('a', 'body') }) 14 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | development: 7 | adapter: sqlite3 8 | database: db/development.sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | # Warning: The database defined as "test" will be erased and 13 | # re-generated from your development database when you run "rake". 14 | # Do not set this db to the same as development or production. 15 | test: 16 | adapter: sqlite3 17 | database: db/test.sqlite3 18 | pool: 5 19 | timeout: 5000 20 | 21 | production: 22 | adapter: sqlite3 23 | database: db/production.sqlite3 24 | pool: 5 25 | timeout: 5000 26 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | * DONE Files should be a wrapped collection that knows how to render itself 3 | * DONE-ish What to do about flash? (maybe port from RailsGenius?) 4 | * DONE Need a generator that sets up the MustacheCompiler front-end stuff and requires the javascript library 5 | * DONE Generators 6 | * Need cache multiget for nested collections 7 | * Fallbacks for history api in other browsers (some kind of modernizr situation?) 8 | * Examples! 9 | * Tests in multiple ruby/rails versions 10 | * Ruby 1.8 / 1.9 / 2.1 11 | * Rails 2.3, 3.2, 4.0 12 | * Mat's idea of conditionally cached properties 13 | * Have to have a way for cache keys to not be aggregated together (performance optimization if you don't want to load a ton of records) -------------------------------------------------------------------------------- /spec/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure your secret_key_base is kept private 11 | # if you're sharing your code publicly. 12 | Dummy::Application.config.secret_key_base = '9391b9284791e3e286b1654e24ba09632094224619c4b9a999b41420b4c7b5b315509c151625eecf0acf3f3c03f54ca78301a6c87eb31a8ae1f27733592209c6' 13 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /lib/perspectives/base.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'perspectives/templating' 3 | require 'perspectives/properties' 4 | require 'perspectives/memoization' 5 | require 'perspectives/params' 6 | require 'perspectives/context' 7 | require 'perspectives/rendering' 8 | require 'perspectives/caching' 9 | 10 | module Perspectives 11 | class Base 12 | include Templating 13 | include Properties 14 | include Memoization 15 | include Params 16 | include Context 17 | include Rendering 18 | include Caching 19 | 20 | class << self 21 | def inherited(base) 22 | base.__send__(:filename=, caller.first[/^(.*?.rb):\d/, 1]) 23 | end 24 | 25 | private 26 | 27 | attr_accessor :filename 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/form.mustache: -------------------------------------------------------------------------------- 1 |
2 | {{#errors}} 3 |
4 |

{{error_count}} prohibited this {{name}} from being saved:

5 | 6 | 11 |
12 | {{/errors}} 13 | 14 | {{#submit_method}} 15 | 16 | {{/submit_method}} 17 | 18 | <% attributes.each do |attribute| -%> 19 | {{{<%= attribute.name %>_field}}} 20 | <% end -%> 21 | 22 |
23 | 24 |
25 |
26 | -------------------------------------------------------------------------------- /spec/lib/perspectives/properties_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Perspectives::Properties do 4 | module ::Users 5 | class Properties < Perspectives::Base 6 | param :user 7 | 8 | property(:name) { user.name } 9 | 10 | nested 'profile' 11 | end 12 | end 13 | 14 | module ::Users 15 | class Profile < Perspectives::Base 16 | delegate_property :blog_url, to: :user 17 | end 18 | end 19 | 20 | let(:context) { {} } 21 | let(:name) { 'Andrew Warner' } 22 | let(:blog_url) { 'a-warner.github.io' } 23 | let(:user) { OpenStruct.new :name => name } 24 | 25 | let(:params) { {:user => user} } 26 | 27 | subject { ::Users::Properties.new(context, params) } 28 | 29 | its(:name) { should == 'Andrew Warner' } 30 | its(:profile) { should_not be_nil } 31 | end 32 | -------------------------------------------------------------------------------- /perspectives.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "perspectives/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "perspectives" 9 | s.version = Perspectives::VERSION 10 | s.authors = ["Andrew Warner"] 11 | s.email = ["wwarner.andrew@gmail.com"] 12 | s.homepage = "https://github.com/RapGenius/perspectives" 13 | s.summary = "Render shared views on the client OR on the server" 14 | s.description = "Render shared views on the client OR on the server" 15 | 16 | s.files = Dir["{app,config,db,lib,vendor}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"] 17 | s.test_files = Dir["spec/**/*"] 18 | 19 | s.add_development_dependency "rails", "~> 4.0.3" 20 | s.add_development_dependency "rspec" 21 | s.add_development_dependency "pry" 22 | 23 | s.add_dependency "mustache", "~> 0.99.5" 24 | s.add_dependency "activesupport" 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | Bundler.require(*Rails.groups) 6 | require "perspectives" 7 | 8 | module Dummy 9 | class Application < Rails::Application 10 | # Settings in config/environments/* take precedence over those specified here. 11 | # Application configuration should go into files in config/initializers 12 | # -- all .rb files in that directory are automatically loaded. 13 | 14 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 15 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 16 | # config.time_zone = 'Central Time (US & Canada)' 17 | 18 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 19 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 20 | # config.i18n.default_locale = :de 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/perspectives/mustache_compiler.rb: -------------------------------------------------------------------------------- 1 | # somewhat ganked from https://github.com/railsware/smt_rails/blob/7d63a3d5c838881690d365f41f45b9082c2611c8/lib/smt_rails/tilt.rb 2 | 3 | require 'tilt' 4 | 5 | module Perspectives 6 | class MustacheCompiler < Tilt::Template 7 | self.default_mime_type = 'application/javascript' 8 | 9 | def prepare 10 | end 11 | 12 | def evaluate(scope, locals, &block) 13 | namespace = "this.#{Perspectives.template_namespace}" 14 | 15 | <<-MustacheTemplate 16 | (function() { 17 | #{namespace} || (#{namespace} = {}); 18 | #{namespace}.views || (#{namespace}.views = {}) 19 | 20 | var data = #{data.inspect} 21 | 22 | Mustache.parse(data) 23 | 24 | #{namespace}.views[#{scope.logical_path.inspect}] = function(object) { 25 | if (!object){ object = {}; } 26 | return Mustache.render(data, object) 27 | }; 28 | 29 | }).call(this); 30 | MustacheTemplate 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/templates/form.rb: -------------------------------------------------------------------------------- 1 | class <%= controller_class_name %>::Form < Perspectives::Base 2 | param :<%= singular_table_name %> 3 | 4 | property(:submit_to) do 5 | <%= singular_table_name %>.new_record? ? <%= plural_table_name %>_path : <%= singular_table_name %>_path(<%= singular_table_name %>) 6 | end 7 | 8 | property(:submit_method) { !<%= singular_table_name %>.new_record? && 'patch' } 9 | 10 | property(:errors) do 11 | errors = <%= singular_table_name %>.errors 12 | if errors.any? 13 | { 14 | error_count: errors.count, 15 | name: <%= singular_table_name %>.class.name.humanize, 16 | error_messages: object.errors.full_messages.map { |msg| {msg: msg } } 17 | } 18 | end 19 | end 20 | 21 | <% attributes.each do |attribute| -%> 22 | nested 'perspectives/forms/text_field', 23 | property: :<%= attribute.name %>_field, 24 | locals: { object: :<%= singular_table_name %>, field: '<%= attribute.name %>' } 25 | 26 | <% end -%> 27 | end 28 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 YOURNAME 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/perspectives/templating.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Templating 3 | def self.included(base) 4 | base.class_eval do 5 | extend ClassMethods 6 | 7 | delegate :_mustache, :_template_key, to: 'self.class' 8 | end 9 | end 10 | 11 | module ClassMethods 12 | def raise_on_context_miss? 13 | Perspectives.raise_on_context_miss? 14 | end 15 | 16 | def template_path 17 | Perspectives.template_path 18 | end 19 | 20 | def _mustache 21 | return @_mustache if defined?(@_mustache) 22 | 23 | klass = self 24 | @_mustache = Class.new(Mustache) do 25 | self.template_name = klass.to_s.underscore 26 | self.raise_on_context_miss = klass.raise_on_context_miss? 27 | self.template_path = klass.template_path 28 | end 29 | end 30 | 31 | def _template_key 32 | @_template_key ||= 33 | _mustache.template_file. 34 | sub(/^#{Regexp.escape(_mustache.template_path)}\//, ''). 35 | chomp(".#{_mustache.template_extension}") 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/perspectives/memoization.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Memoization 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | module ClassMethods 8 | def property(name, *names, &block) 9 | super.tap { memoize_property(name) if names.empty? } 10 | end 11 | 12 | def memoize_property(prop_name) 13 | raise ArgumentError, "No method #{prop_name}" unless method_defined?(prop_name) 14 | 15 | original_property_method = "_unmemoized_#{prop_name}" 16 | raise ArgumentError, "Already memoized property #{prop_name.inspect}" if method_defined?(original_property_method) 17 | 18 | ivar = "@_memoized_#{prop_name.to_s.sub(/\?\Z/, '_query').sub(/!\Z/, '_bang')}" 19 | alias_method original_property_method, prop_name 20 | 21 | class_eval <<-CODE, __FILE__, __LINE__ + 1 22 | def #{prop_name} # def name 23 | return #{ivar} if defined?(#{ivar}) # return @_memoized_name if defined?(@_memoized_name) 24 | #{ivar} = #{original_property_method} # @_memoized_name = _unmemoized_name 25 | end 26 | CODE 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | end 30 | -------------------------------------------------------------------------------- /lib/generators/perspectives/install.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | 3 | module Perspectives 4 | module Generators 5 | class InstallGenerator < ::Rails::Generators::Base 6 | source_root File.expand_path("../templates", __FILE__) 7 | desc "Installs Perspectives and configures the Asset Pipeline" 8 | 9 | def add_assets 10 | js_manifest = 'app/assets/javascripts/application.js' 11 | 12 | if File.exist?(js_manifest) 13 | requirements = <<-REQS.strip 14 | //= require mustache-0.8.1 15 | //= require perspectives 16 | //= require perspectives_views 17 | //= require_tree ../../mustaches 18 | REQS 19 | 20 | gsub_file js_manifest, %r{^//= require turbolinks$}, '' 21 | 22 | insert_into_file js_manifest, "#{requirements}", :after => "jquery_ujs\n" 23 | insert_into_file js_manifest, "\n$(function() { $(document).perspectives('a', 'body') })\n", :after => "//= require_tree .\n" 24 | else 25 | copy_file "application.js", js_manifest 26 | end 27 | end 28 | 29 | def configure_directories 30 | %w(app/mustaches app/perspectives).each do |dir| 31 | empty_directory dir 32 | create_file File.join(dir, '.keep') 33 | end 34 | end 35 | end 36 | end 37 | end 38 | 39 | -------------------------------------------------------------------------------- /lib/perspectives.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'mustache' 3 | require 'active_support/core_ext/string/inflections' 4 | require 'active_support/core_ext/module/delegation' 5 | require 'active_support/core_ext/array/extract_options' 6 | require 'active_support/core_ext/hash/keys' 7 | require 'active_support/core_ext/class/attribute' 8 | require 'perspectives/collection' 9 | require 'perspectives/base' 10 | require 'perspectives/forms' 11 | require 'perspectives/configuration' 12 | require 'perspectives/mustache_compiler' 13 | require 'perspectives/railtie' if defined?(Rails) # TODO: older rails support! 14 | 15 | module Perspectives 16 | class << self 17 | def template_namespace 18 | 'Perspectives' 19 | end 20 | 21 | def configure 22 | yield(configuration) 23 | end 24 | 25 | delegate :cache, :caching?, :template_path, :raise_on_context_miss?, to: :configuration 26 | delegate :expand_cache_key, to: 'ActiveSupport::Cache' 27 | 28 | def resolve_partial_class_name(top_level_view_namespace, name) 29 | return name if name.is_a?(Class) && name < Perspectives::Base 30 | 31 | camelized = name.to_s.camelize 32 | 33 | [top_level_view_namespace, camelized].join('::').constantize 34 | rescue NameError 35 | camelized.constantize 36 | end 37 | 38 | private 39 | 40 | def configuration 41 | @configuration ||= Configuration.new 42 | end 43 | end 44 | 45 | configure do |c| 46 | c.raise_on_context_miss = true 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

We're sorry, but something went wrong.

54 |
55 |

If you are the application owner check the logs for more information.

56 | 57 | 58 | -------------------------------------------------------------------------------- /lib/perspectives/params.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Params 3 | def self.included(base) 4 | base.class_eval do 5 | extend ClassMethods 6 | 7 | class_attribute :_required_params, :_optional_params 8 | self._required_params = [] 9 | self._optional_params = [] 10 | attr_reader :_params, :context 11 | end 12 | end 13 | 14 | def initialize(context = {}, params = {}) 15 | raise ArgumentError, "Params is not a hash!" unless params.is_a?(Hash) 16 | @_params = params.symbolize_keys 17 | @context = context 18 | assert_valid_params! 19 | end 20 | 21 | private 22 | 23 | def assert_valid_params! 24 | missing = _required_params.select { |l| !_params.key?(l) } 25 | unknown = _params.keys - (_required_params + _optional_params) 26 | 27 | if missing.any? 28 | raise ArgumentError, "Missing #{missing.join(', ').inspect} while initializing #{self.class}!" 29 | elsif unknown.any? 30 | raise ArgumentError, "Unrecognized params #{unknown.join(', ').inspect} while initializing #{self.class}!" 31 | end 32 | end 33 | 34 | module ClassMethods 35 | def param(*param_names) 36 | options = param_names.extract_options! 37 | 38 | if options[:allow_nil] 39 | self._optional_params += param_names 40 | else 41 | self._required_params += param_names 42 | end 43 | 44 | param_names.each { |n| define_method(n) { _params[n] } } 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

The change you wanted was rejected.

54 |

Maybe you tried to change something you didn't have access to.

55 |
56 |

If you are the application owner check the logs for more information.

57 | 58 | 59 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

The page you were looking for doesn't exist.

54 |

You may have mistyped the address or the page may have moved.

55 |
56 |

If you are the application owner check the logs for more information.

57 | 58 | 59 | -------------------------------------------------------------------------------- /lib/generators/perspectives/scaffold/scaffold_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/resource_helpers' 3 | 4 | module Perspectives 5 | module Generators 6 | class ScaffoldGenerator < Rails::Generators::NamedBase 7 | include Rails::Generators::ResourceHelpers 8 | 9 | argument :attributes, type: :array, default: [], banner: "field:type field:type" 10 | 11 | source_root File.expand_path("../templates", __FILE__) 12 | 13 | def create_root_folders 14 | empty_directory mustache_path 15 | empty_directory perspectives_path 16 | end 17 | 18 | def copy_view_files 19 | available_views.each do |view| 20 | template "#{view}.mustache", mustache_path("#{view}.mustache") 21 | template "#{view}.rb", perspectives_path("#{view}.rb") 22 | end 23 | end 24 | 25 | # hook_for :form_builder, :as => :scaffold 26 | 27 | def copy_form_file 28 | filename = 'form.mustache' 29 | template filename, mustache_path(filename) 30 | 31 | filename = 'form.rb' 32 | template filename, perspectives_path(filename) 33 | end 34 | 35 | protected 36 | 37 | def available_views 38 | %w(index tiny show new edit) 39 | end 40 | 41 | def handler 42 | :perspectives 43 | end 44 | 45 | def mustache_path(filename = nil) 46 | File.join(*["app/mustaches", controller_file_path, filename].compact) 47 | end 48 | 49 | def perspectives_path(filename = nil) 50 | File.join(*["app/perspectives", controller_file_path, filename].compact) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::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_assets = 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 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Print deprecation notices to the stderr. 35 | config.active_support.deprecation = :stderr 36 | end 37 | -------------------------------------------------------------------------------- /lib/perspectives/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'perspectives/controller_additions' 2 | require 'perspectives/responder' 3 | require 'perspectives/active_record' 4 | require 'generators/perspectives/install.rb' 5 | require 'generators/perspectives/scaffold/scaffold_generator.rb' 6 | 7 | module Perspectives 8 | class Railtie < Rails::Railtie 9 | if ::Rails.version.to_s >= "3.1" 10 | config.app_generators.template_engine :perspectives 11 | config.app_generators.templates << File.expand_path('../../generators/perspectives/templates', __FILE__) 12 | else 13 | config.generators.template_engine :perspectives 14 | config.generators.templates << File.expand_path('../../generators/perspectives/templates', __FILE__) 15 | end 16 | 17 | initializer 'perspectives.railtie' do |app| 18 | app.config.autoload_paths += ['app/perspectives'] 19 | app.config.watchable_dirs['app/mustaches'] = [:mustache] 20 | 21 | app.config.assets.paths << File.expand_path('../../../vendor/assets/javascripts', __FILE__) 22 | 23 | Perspectives::Base.class_eval do 24 | include ActionView::Helpers 25 | include app.routes.url_helpers 26 | include ERB::Util 27 | include Perspectives::ActiveRecord 28 | end 29 | 30 | Perspectives.configure do |c| 31 | c.template_path = app.root.join('app', 'mustaches') 32 | end 33 | 34 | app.assets.register_engine '.mustache', Perspectives::MustacheCompiler 35 | app.config.assets.paths << Perspectives.template_path 36 | 37 | # TODO: probably bail if we're not in rails3/sprockets land... 38 | # TODO: probably cache asset version in prod? 39 | ActionController::Base.send(:include, Perspectives::ControllerAdditions) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | # The priority is based upon order of creation: first created -> highest priority. 3 | # See how all your routes lay out with "rake routes". 4 | 5 | # You can have the root of your site routed with "root" 6 | # root 'welcome#index' 7 | 8 | # Example of regular route: 9 | # get 'products/:id' => 'catalog#view' 10 | 11 | # Example of named route that can be invoked with purchase_url(id: product.id) 12 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase 13 | 14 | # Example resource route (maps HTTP verbs to controller actions automatically): 15 | # resources :products 16 | 17 | # Example resource route with options: 18 | # resources :products do 19 | # member do 20 | # get 'short' 21 | # post 'toggle' 22 | # end 23 | # 24 | # collection do 25 | # get 'sold' 26 | # end 27 | # end 28 | 29 | # Example resource route with sub-resources: 30 | # resources :products do 31 | # resources :comments, :sales 32 | # resource :seller 33 | # end 34 | 35 | # Example resource route with more complex sub-resources: 36 | # resources :products do 37 | # resources :comments 38 | # resources :sales do 39 | # get 'recent', on: :collection 40 | # end 41 | # end 42 | 43 | # Example resource route with concerns: 44 | # concern :toggleable do 45 | # post 'toggle' 46 | # end 47 | # resources :posts, concerns: :toggleable 48 | # resources :photos, concerns: :toggleable 49 | 50 | # Example resource route within a namespace: 51 | # namespace :admin do 52 | # # Directs /admin/products/* to Admin::ProductsController 53 | # # (app/controllers/admin/products_controller.rb) 54 | # resources :products 55 | # end 56 | end 57 | -------------------------------------------------------------------------------- /lib/perspectives/caching.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Caching 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | 6 | base.class_eval do 7 | class_attribute :_cache_key_additions_block 8 | delegate :_class_source_digest, :_mustache_source_digest, to: 'self.class' 9 | end 10 | end 11 | 12 | def render_html 13 | _with_cache('html') { super } 14 | end 15 | 16 | def to_json(options = {}) 17 | _with_cache('json') { super } 18 | end 19 | 20 | private 21 | 22 | def _cache 23 | Perspectives.cache 24 | end 25 | 26 | def _expand_cache_key(*args, &block) 27 | Perspectives.expand_cache_key(*args, &block) 28 | end 29 | 30 | def _with_cache(*key_additions) 31 | return yield unless _caching? 32 | 33 | _cache.fetch(_expand_cache_key(_cache_key.concat(key_additions))) { yield } 34 | end 35 | 36 | def _cache_key 37 | return [] unless _caching? 38 | 39 | [].tap do |key| 40 | key << self.class.to_s 41 | key << _mustache_source_digest 42 | key << _class_source_digest 43 | key.concat(Array(instance_eval(&_cache_key_additions_block))) if _cache_key_additions_block 44 | key.concat _dependent_cache_keys 45 | end 46 | end 47 | 48 | def _caching? 49 | Perspectives.caching? && !!_cache_key_additions_block 50 | end 51 | 52 | def _dependent_cache_keys 53 | _nested_perspectives.each_with_object([]) do |property_name, key| 54 | perspectives = __send__(property_name) 55 | 56 | case perspectives 57 | when NilClass 58 | when Array 59 | key.concat(perspectives.map { |p| p.__send__(:_cache_key) }.flatten) 60 | else 61 | key.concat(perspectives.__send__(:_cache_key)) 62 | end 63 | end 64 | end 65 | 66 | module ClassMethods 67 | def cache(&block) 68 | raise ArgumentError, "No block given" unless block_given? 69 | 70 | self._cache_key_additions_block = block 71 | end 72 | 73 | def _class_source_digest 74 | @_class_source_digest ||= Digest::MD5.hexdigest(File.read(filename)) 75 | end 76 | 77 | def _mustache_source_digest 78 | @_mustache_source_digest ||= Digest::MD5.hexdigest(_mustache.template.source) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | perspectives (0.0.2) 5 | activesupport 6 | mustache (~> 0.99.5) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionmailer (4.0.4) 12 | actionpack (= 4.0.4) 13 | mail (~> 2.5.4) 14 | actionpack (4.0.4) 15 | activesupport (= 4.0.4) 16 | builder (~> 3.1.0) 17 | erubis (~> 2.7.0) 18 | rack (~> 1.5.2) 19 | rack-test (~> 0.6.2) 20 | activemodel (4.0.4) 21 | activesupport (= 4.0.4) 22 | builder (~> 3.1.0) 23 | activerecord (4.0.4) 24 | activemodel (= 4.0.4) 25 | activerecord-deprecated_finders (~> 1.0.2) 26 | activesupport (= 4.0.4) 27 | arel (~> 4.0.0) 28 | activerecord-deprecated_finders (1.0.3) 29 | activesupport (4.0.4) 30 | i18n (~> 0.6, >= 0.6.9) 31 | minitest (~> 4.2) 32 | multi_json (~> 1.3) 33 | thread_safe (~> 0.1) 34 | tzinfo (~> 0.3.37) 35 | arel (4.0.2) 36 | atomic (1.1.15) 37 | builder (3.1.4) 38 | coderay (1.1.0) 39 | diff-lcs (1.2.5) 40 | erubis (2.7.0) 41 | hike (1.2.3) 42 | i18n (0.6.9) 43 | mail (2.5.4) 44 | mime-types (~> 1.16) 45 | treetop (~> 1.4.8) 46 | method_source (0.8.2) 47 | mime-types (1.25.1) 48 | minitest (4.7.5) 49 | multi_json (1.9.0) 50 | mustache (0.99.5) 51 | polyglot (0.3.4) 52 | pry (0.9.12.6) 53 | coderay (~> 1.0) 54 | method_source (~> 0.8) 55 | slop (~> 3.4) 56 | rack (1.5.2) 57 | rack-test (0.6.2) 58 | rack (>= 1.0) 59 | rails (4.0.4) 60 | actionmailer (= 4.0.4) 61 | actionpack (= 4.0.4) 62 | activerecord (= 4.0.4) 63 | activesupport (= 4.0.4) 64 | bundler (>= 1.3.0, < 2.0) 65 | railties (= 4.0.4) 66 | sprockets-rails (~> 2.0.0) 67 | railties (4.0.4) 68 | actionpack (= 4.0.4) 69 | activesupport (= 4.0.4) 70 | rake (>= 0.8.7) 71 | thor (>= 0.18.1, < 2.0) 72 | rake (10.1.1) 73 | rspec (2.14.1) 74 | rspec-core (~> 2.14.0) 75 | rspec-expectations (~> 2.14.0) 76 | rspec-mocks (~> 2.14.0) 77 | rspec-core (2.14.8) 78 | rspec-expectations (2.14.5) 79 | diff-lcs (>= 1.1.3, < 2.0) 80 | rspec-mocks (2.14.6) 81 | slop (3.5.0) 82 | sprockets (2.12.0) 83 | hike (~> 1.2) 84 | multi_json (~> 1.0) 85 | rack (~> 1.0) 86 | tilt (~> 1.1, != 1.3.0) 87 | sprockets-rails (2.0.1) 88 | actionpack (>= 3.0) 89 | activesupport (>= 3.0) 90 | sprockets (~> 2.8) 91 | thor (0.18.1) 92 | thread_safe (0.2.0) 93 | atomic (>= 1.1.7, < 2) 94 | tilt (1.4.1) 95 | treetop (1.4.15) 96 | polyglot 97 | polyglot (>= 0.3.1) 98 | tzinfo (0.3.39) 99 | 100 | PLATFORMS 101 | ruby 102 | 103 | DEPENDENCIES 104 | perspectives! 105 | pry 106 | rails (~> 4.0.3) 107 | rspec 108 | -------------------------------------------------------------------------------- /lib/generators/perspectives/templates/rails/scaffold_controller/controller.rb: -------------------------------------------------------------------------------- 1 | <% if namespaced? -%> 2 | require_dependency "<%= namespaced_file_path %>/application_controller" 3 | 4 | <% end -%> 5 | <% module_namespacing do -%> 6 | class <%= controller_class_name %>Controller < ApplicationController 7 | before_action :set_<%= singular_table_name %>, only: [:show, :edit, :update, :destroy] 8 | 9 | perspectives_actions 10 | 11 | # GET <%= route_url %> 12 | def index 13 | respond_with(perspective('<%= plural_table_name %>/index', all_<%= plural_table_name %>: <%= orm_class.all(class_name) %>)) 14 | end 15 | 16 | # GET <%= route_url %>/1 17 | def show 18 | respond_with(perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: @<%= singular_table_name %>)) 19 | end 20 | 21 | # GET <%= route_url %>/new 22 | def new 23 | respond_with(perspective('<%= plural_table_name %>/new', <%= singular_table_name %>: <%= orm_class.build(class_name) %>)) 24 | end 25 | 26 | # GET <%= route_url %>/1/edit 27 | def edit 28 | respond_with(perspective('<%= plural_table_name %>/edit', <%= singular_table_name %>: @<%= singular_table_name %>)) 29 | end 30 | 31 | # POST <%= route_url %> 32 | def create 33 | <%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %> 34 | 35 | if <%= orm_instance.save %> 36 | flash[:notice] = <%= "'#{human_name} was successfully created.'" %> 37 | 38 | respond_to do |format| 39 | format.html { redirect_to <%= singular_table_name %> } 40 | format.json { render json: perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: <%= singular_table_name %>), status: :created, location: <%= singular_table_name %> } 41 | end 42 | else 43 | respond_with(perspective('<%= plural_table_name %>/new', <%= singular_table_name %>: <%= singular_table_name %>)) 44 | end 45 | end 46 | 47 | # PATCH/PUT <%= route_url %>/1 48 | def update 49 | if @<%= orm_instance.update("#{singular_table_name}_params") %> 50 | flash[:notice] = <%= "'#{human_name} was successfully updated.'" %> 51 | 52 | respond_to do |format| 53 | format.html { redirect_to @<%= singular_table_name %> } 54 | format.json { render json: perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: @<%= singular_table_name %>) } 55 | end 56 | else 57 | respond_with(perspective('<%= plural_table_name %>/edit', <%= singular_table_name %>: @<%= singular_table_name %>)) 58 | end 59 | end 60 | 61 | # DELETE <%= route_url %>/1 62 | def destroy 63 | @<%= orm_instance.destroy %> 64 | redirect_to <%= index_helper %>_url, notice: <%= "'#{human_name} was successfully destroyed.'" %> 65 | end 66 | 67 | private 68 | # Use callbacks to share common setup or constraints between actions. 69 | def set_<%= singular_table_name %> 70 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params[:id]") %> 71 | end 72 | 73 | # Only allow a trusted parameter "white list" through. 74 | def <%= "#{singular_table_name}_params" %> 75 | <%- if attributes_names.empty? -%> 76 | params[<%= ":#{singular_table_name}" %>] 77 | <%- else -%> 78 | params.require(<%= ":#{singular_table_name}" %>).permit(<%= attributes_names.map { |name| ":#{name}" }.join(', ') %>) 79 | <%- end -%> 80 | end 81 | end 82 | <% end -%> 83 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both thread web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | # Disable Rails's static asset server (Apache or nginx will already do this). 23 | config.serve_static_assets = false 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Generate digests for assets URLs. 33 | config.assets.digest = true 34 | 35 | # Version of your assets, change this if you want to expire all your assets. 36 | config.assets.version = '1.0' 37 | 38 | # Specifies the header that your server uses for sending files. 39 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 41 | 42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 43 | # config.force_ssl = true 44 | 45 | # Set to :debug to see everything in the log. 46 | config.log_level = :info 47 | 48 | # Prepend all log lines with the following tags. 49 | # config.log_tags = [ :subdomain, :uuid ] 50 | 51 | # Use a different logger for distributed setups. 52 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 53 | 54 | # Use a different cache store in production. 55 | # config.cache_store = :mem_cache_store 56 | 57 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 58 | # config.action_controller.asset_host = "http://assets.example.com" 59 | 60 | # Precompile additional assets. 61 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 62 | # config.assets.precompile += %w( search.js ) 63 | 64 | # Ignore bad email addresses and do not raise email delivery errors. 65 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 66 | # config.action_mailer.raise_delivery_errors = false 67 | 68 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 69 | # the I18n.default_locale when a translation can not be found). 70 | config.i18n.fallbacks = true 71 | 72 | # Send deprecation notices to registered listeners. 73 | config.active_support.deprecation = :notify 74 | 75 | # Disable automatic flushing of the log to improve performance. 76 | # config.autoflush_log = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | end 81 | -------------------------------------------------------------------------------- /lib/perspectives/properties.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module Properties 3 | CantUseLambdas = Class.new(StandardError) 4 | 5 | def self.included(base) 6 | base.class_eval do 7 | extend ClassMethods 8 | class_attribute :_properties, :_nested_perspectives 9 | 10 | self._properties = [] 11 | self._nested_perspectives = [] 12 | end 13 | end 14 | 15 | private 16 | 17 | def _property_map 18 | _properties.each_with_object({}) do |p, h| 19 | h[p] = __send__(p) 20 | 21 | if h[p].is_a?(Proc) 22 | raise CantUseLambdas, "You cannot use the lambda mustache behavior if you want to render on the client...it's not portable!" 23 | end 24 | end 25 | end 26 | 27 | def _resolve_partial_class_name(name) 28 | Perspectives.resolve_partial_class_name(self.class.to_s.split('::').first, name) 29 | end 30 | 31 | module ClassMethods 32 | def property(name, *names, &block) 33 | unless names.empty? 34 | raise ArgumentError, "Can't define multiple properties and pass a block" if block_given? 35 | return names.push(name).each(&public_method(:property)) 36 | end 37 | 38 | self._properties += [name] 39 | 40 | unless method_defined?(name) 41 | raise ArgumentError, "No method #{name} and no block given" unless block_given? 42 | 43 | define_method(name, &block) 44 | end 45 | end 46 | 47 | def nested(name, args = {}, &block) 48 | locals, options = args, {} 49 | 50 | if args[:locals] 51 | locals = args[:locals] 52 | options = args.except(:locals) 53 | end 54 | 55 | _setup_nested(name, locals, options, &block) 56 | end 57 | 58 | def nested_collection(name, *args, &block) 59 | options = args.extract_options! 60 | collection = options.fetch(:collection, args.first) 61 | raise ArgumentError, "You must either pass in a collection, or pass a collection option" unless collection 62 | 63 | _setup_nested(name, options.fetch(:locals, {}), options.merge!(:collection => collection), &block) 64 | end 65 | 66 | def delegate_property(*props) 67 | delegate *props 68 | opts = props.pop 69 | 70 | prop_names = props 71 | 72 | if opts[:prefix] 73 | prefix = opts[:prefix] == true ? opts[:to] : opts[:prefix] 74 | prop_names = prop_names.map { |n| "#{prefix}_#{n}" } 75 | end 76 | 77 | prop_names.each(&public_method(:property)) 78 | end 79 | 80 | private 81 | 82 | def _setup_nested(name, locals, options, &block) 83 | name_str, name_sym = name.to_s, name.to_sym 84 | 85 | prop_name = options.fetch(:property, _default_property_name(name_str, options)).to_sym 86 | 87 | unless block_given? || method_defined?(prop_name) 88 | local_procs = locals.each_with_object({}) { |(k, v), h| h[k.to_sym] = v.respond_to?(:to_proc) ? v.to_proc : proc { v } } 89 | nested_klass_ivar = :"@_#{name_str.underscore.gsub('/', '__')}_klass" 90 | 91 | define_method(prop_name) do 92 | klass = 93 | if self.class.instance_variable_defined?(nested_klass_ivar) 94 | self.class.instance_variable_get(nested_klass_ivar) 95 | else 96 | self.class.instance_variable_set(nested_klass_ivar, _resolve_partial_class_name(name)) 97 | end 98 | 99 | if options[:unless] 100 | return if instance_exec(self, &options[:unless]) 101 | elsif options[:if] 102 | return unless instance_exec(self, &options[:if]) 103 | end 104 | 105 | realized_locals = local_procs.each_with_object({}) { |(k, v), h| h[k] = instance_exec(self, &v) } 106 | 107 | if options.key?(:collection) 108 | collection = instance_exec(self, &options[:collection]) 109 | return unless collection.present? 110 | 111 | as = options.fetch(:as, collection.first.class.base_class.name.downcase).to_sym 112 | Collection.new(collection.map { |o| klass.new(context, realized_locals.merge(as => o)) }) 113 | else 114 | klass.new(context, realized_locals) 115 | end 116 | end 117 | end 118 | 119 | property(prop_name, &block) 120 | self._nested_perspectives += [prop_name] 121 | end 122 | 123 | def _default_property_name(name_str, options) 124 | name = name_str.split('/').last 125 | name = name.pluralize if options.key?(:collection) 126 | name 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/perspectives.js: -------------------------------------------------------------------------------- 1 | (function($, window, document, undefined) { 2 | window.Perspectives = window.Perspectives || {} 3 | 4 | var renderTemplateData = function(data) { 5 | var view = {} 6 | 7 | for(var key in data) { 8 | if (!data.hasOwnProperty(key)) continue 9 | 10 | if ($.isArray(data[key])) { 11 | view[key] = $.map(data[key], function(value) { 12 | var new_value = $.extend(!!'deep_copy', {}, value) 13 | 14 | if (value['_template_key']) { 15 | new_value['to_s'] = new_value['to_html'] = renderTemplateData(value) 16 | } 17 | 18 | return new_value 19 | }) 20 | 21 | view[key].toString = function() { return $.map(this, function(value) { return value.to_html }).join('') } 22 | } else if (data[key] && typeof data[key] === 'object' && data[key]['_template_key']) { 23 | view[key] = renderTemplateData(data[key]) 24 | } else { 25 | view[key] = data[key] 26 | } 27 | } 28 | 29 | view['to_s'] = function() { toString() } 30 | 31 | return Perspectives.views[data._template_key](view) 32 | } 33 | 34 | // pretty much ganked from pjax... 35 | var locationReplace = function(url) { 36 | window.history.replaceState(null, "", "#") 37 | window.location.replace(url) 38 | } 39 | 40 | var perspectivesVersion = function() { 41 | return $('meta').filter(function() { 42 | var name = $(this).attr('http-equiv') 43 | return name && name.toUpperCase() === 'X-PERSPECTIVES-VERSION' 44 | }).attr('content') 45 | } 46 | 47 | var renderResponse = function(options) { 48 | var $globalContainer = globalPerspectivesContainer(), 49 | $container = $(options.container).length ? $(options.container) : $globalContainer 50 | console.time('perspectives rendering') 51 | 52 | var version = perspectivesVersion() || '' 53 | if (version.length && version !== xhr.getResponseHeader('X-Perspectives-Version')) { 54 | locationReplace(options.href) 55 | return false 56 | } 57 | 58 | var $rendered = $(renderTemplateData(options.json)) 59 | 60 | $container.html($rendered) 61 | 62 | if (!options.noPushState) { 63 | window.history.pushState({container: globalPerspectivesContainer().selector}, options.href, options.href) 64 | } 65 | 66 | $(document).trigger('perspectives:load', options.xhr) 67 | 68 | console.timeEnd('perspectives rendering') 69 | } 70 | 71 | var globalPerspectivesContainer = function() { 72 | return $('[data-global-perspectives-target]') 73 | } 74 | 75 | var handlePerspectivesClick = function(container) { 76 | var $this = $(this) 77 | 78 | navigate({ 79 | href: this.href, 80 | container: $this.attr('data-perspectives-target'), 81 | fullPage: !!$this.attr('data-perspectives-full-page'), 82 | element: $this 83 | }) 84 | 85 | return false 86 | } 87 | 88 | var navigate = function(options) { 89 | var $element = $(options.element || document) 90 | 91 | $.ajax({ 92 | method: 'GET', 93 | url: options.href, 94 | dataType: 'json', 95 | headers: { 'x-perspectives-full-page': !!options.fullPage } 96 | }).success(function(json, status, xhr) { 97 | $element.trigger('perspectives:response', { 98 | json: json, 99 | status: status, 100 | xhr: xhr, 101 | href: options.href, 102 | container: options.container, 103 | noPushState: options.noPushState 104 | }) 105 | }) 106 | } 107 | 108 | $(document).on('perspectives:response', function(e, options) { renderResponse(options) }) 109 | 110 | $(document).on('ajax:success', function(event, data, status, xhr) { 111 | if (!xhr.getResponseHeader('Content-Type').match(/json/i)) return 112 | 113 | var $form = $(event.target), 114 | $globalContainer = globalPerspectivesContainer(), 115 | href = xhr.getResponseHeader('Location') || $form.attr('action'), 116 | container = $form.attr('data-perspectives-target') 117 | 118 | $form.trigger('perspectives:response', { 119 | json: data, 120 | status: status, 121 | xhr: xhr, 122 | href: href, 123 | container: container 124 | }) 125 | 126 | return false 127 | }) 128 | 129 | $(window).on('popstate.perspectives', function(event) { 130 | var originalEvent = event.originalEvent 131 | if(originalEvent && originalEvent.state && originalEvent.state.container) { 132 | navigate({ 133 | href: window.location.href, 134 | container: originalEvent.state.container, 135 | fullPage: true, 136 | noPushState: true 137 | }) 138 | } 139 | }) 140 | 141 | $.fn.perspectives = function(selector, container) { 142 | $(container).attr('data-global-perspectives-target', true) 143 | 144 | $(this).on('click', selector, function() { 145 | return handlePerspectivesClick.bind(this)(container) 146 | }) 147 | } 148 | 149 | Perspectives.renderTemplateData = Perspectives.render = renderTemplateData 150 | Perspectives.navigate = navigate 151 | Perspectives.renderResponse = renderResponse 152 | })(jQuery, window, document) 153 | -------------------------------------------------------------------------------- /lib/perspectives/controller_additions.rb: -------------------------------------------------------------------------------- 1 | module Perspectives 2 | module ControllerAdditions 3 | def self.included(base) 4 | base.before_filter :set_perspectives_version 5 | base.helper_method :assets_meta_tag 6 | base.class_attribute :perspectives_enabled_actions 7 | delegate :perspectives_enabled_actions, to: 'self.class' 8 | base.helper_method :perspective 9 | 10 | base.class_attribute :perspectives_wrapping 11 | base.perspectives_wrapping = [] 12 | 13 | base.extend(ClassMethods) 14 | 15 | delegate 'resolve_perspective_class_name', to: 'self.class' 16 | end 17 | 18 | private 19 | 20 | unless defined?(ActionController::Responder) 21 | def respond_to(*mimes, &block) 22 | return super if block_given? || mimes.many? || !mimes.first.is_a?(Perspectives::Base) 23 | 24 | perspectives_object = mimes.first 25 | perspectives_object = wrap_perspective(perspectives_object) if wrap_perspective? 26 | 27 | super() do |format| 28 | format.html { render text: perspectives_object.to_html, layout: :default } 29 | format.json { render json: perspectives_object } 30 | end 31 | end 32 | end 33 | 34 | def perspective(name, params_or_options = {}) 35 | if params_or_options.key?(:context) || params_or_options.key?(:params) 36 | params = params_or_options.fetch(:params, {}) 37 | context = params_or_options.fetch(:context, default_context) 38 | else 39 | context = default_context 40 | params = params_or_options 41 | end 42 | 43 | resolve_perspective_class_name(name).new(context, params) 44 | end 45 | 46 | def respond_with(*resources, &block) 47 | return super unless wrap_perspective? && resources.first.is_a?(Perspectives::Base) 48 | 49 | wrapped = wrap_perspective(resources.shift) 50 | 51 | super(*resources.unshift(wrapped), &block) 52 | end 53 | 54 | def default_context 55 | {} 56 | end 57 | 58 | def assets_version 59 | Rails.application.assets.index.each_file.to_a.map { |f| File.new(f).mtime }.max.to_i 60 | end 61 | 62 | def assets_meta_tag 63 | view_context.content_tag(:meta, nil, :'http-equiv' => 'x-perspectives-version', content: assets_version) 64 | end 65 | 66 | def set_perspectives_version 67 | response.headers['X-Perspectives-Version'] = assets_version.to_s 68 | end 69 | 70 | def perspectives_enabled_action? 71 | action_enabled_by?(perspectives_enabled_actions) 72 | end 73 | 74 | def perspectives_wrapper 75 | return unless perspectives_enabled_action? && (request.headers['X-Perspectives-Full-Page'].to_s == 'true' || !request.xhr?) 76 | 77 | perspectives_wrapping.find do |_, options| 78 | next unless action_enabled_by?(options) 79 | 80 | if options[:unless].present? 81 | !options[:unless].call(self) 82 | elsif options[:if].present? 83 | options[:if].call(self) 84 | else 85 | true 86 | end 87 | end 88 | end 89 | alias_method :wrap_perspective?, :perspectives_wrapper 90 | 91 | def wrap_perspective(unwrapped_perspective) 92 | perspective_klass, options = *perspectives_wrapper 93 | perspective_klass.new(unwrapped_perspective.context, options[:args].call(self, unwrapped_perspective)) 94 | end 95 | 96 | def action_enabled_by?(options) 97 | return false if options.nil? 98 | 99 | action = action_name.to_s 100 | 101 | if options[:except] 102 | !options[:except].include?(action) 103 | elsif options[:only] 104 | options[:only].include?(action) 105 | else 106 | true 107 | end 108 | end 109 | 110 | module ClassMethods 111 | def perspectives_actions(options = {}) 112 | self.perspectives_enabled_actions = options.slice(:only, :except).each_with_object({}) do |(k, v), h| 113 | h[k] = Array(v).map(&:to_s) 114 | end 115 | 116 | respond_to :html, :json, options 117 | self.responder = Perspectives::Responder 118 | end 119 | 120 | def wrapped_with(perspective, options = {}) 121 | perspective_klass = resolve_perspective_class_name(perspective) 122 | 123 | options[:only] = Array(options[:only]).map(&:to_s) if options[:only] 124 | options[:except] = Array(options[:except]).map(&:to_s) if options[:except] 125 | 126 | options[:if] ||= lambda { |c| c.params[perspective_klass.id_param].present? } 127 | options[:args] ||= lambda do |controller, perspective| 128 | { 129 | perspective_klass.active_record_klass.name.underscore => perspective_klass.active_record_klass.find(controller.params[perspective_klass.id_param]), 130 | options.fetch(:as, controller_name.underscore.singularize) => perspective 131 | } 132 | end 133 | 134 | self.perspectives_wrapping += [[perspective_klass, options]] 135 | end 136 | 137 | def resolve_perspective_class_name(name) 138 | Perspectives.resolve_partial_class_name(controller_name.camelize, name) 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Perspectives 2 | 3 | Render views on the client OR on the server. Perspectives breaks traditional Rails views into 4 | a logic-less Mustache template and a "Perspective", which allows you to render views either on 5 | the client or on the server. Building up a thick client that shares the rendering stack 6 | with the server allows sites to be SEO friendly and render HTML from deep links on the server 7 | for a great client experience, while also incrementally rendering parts of the page if the 8 | user already has the site loaded in a browser. 9 | 10 | Perspectives was debuted at [RailsConf 2014](https://www.youtube.com/watch?v=WAN_P1m76GQ). 11 | 12 | ## Getting Started 13 | 14 | In your Gemfile: 15 | 16 | ```ruby 17 | gem 'perspectives' 18 | ``` 19 | 20 | Run the installer: 21 | 22 | ```sh 23 | $ rails generate perspectives:install 24 | ``` 25 | 26 | Scaffold a resource if you want an example: 27 | 28 | ```sh 29 | $ rails generate scaffold post title:string body:text 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Vanilla perspectives 35 | 36 | Perspectives live in `app/perspectives`. If you have a perspective called `app/perspectives/users/show.rb`, 37 | then it will render the corresponding template from `app/mustaches/users/show.mustache`. For example, the following 38 | perspective: 39 | 40 | ```ruby 41 | # app/perspectives/users/show.rb 42 | class Users::Show < Perspectives::Base 43 | property(:name) { 'Andrew' } 44 | end 45 | ``` 46 | 47 | and template 48 | 49 | ```mustache 50 | 51 | Hello, {{name}}! 52 | ``` 53 | 54 | would render "`Hello, Andrew!`". To render it yourself, you could write: 55 | 56 | ```ruby 57 | Users::Show.new.to_html 58 | Users::Show.new.to_json 59 | ``` 60 | 61 | In order for a property to be available in the Mustache template, you have to explicitly mark it 62 | as a property in a perspective. For example: 63 | 64 | ```ruby 65 | class Users::Show < Perspectives::Base 66 | property(:name) { 'Andrew' } # declare a property 67 | 68 | def another_property 69 | 'something else' 70 | end 71 | property :another_property # mark a method as a property 72 | end 73 | ``` 74 | 75 | If you expect certain inputs, you define those are "params". For example: 76 | 77 | ```ruby 78 | class Users::Show < Perspectives::Base 79 | param :user # expects to be passed a "user" object, available as "user" 80 | param :admin, allow_nil: true # can be optionally passed an "admin" param 81 | end 82 | ``` 83 | 84 | All perspectives also get passed a "context" object when being created. For example, to 85 | initialize the above object, we might write: 86 | 87 | ```ruby 88 | user = User.find(params[:id]) 89 | context = {current_user: current_user} 90 | Users::Show.new(context, user: user) 91 | ``` 92 | 93 | Which would make a `current_user` method available in the perspective. (any key in the context 94 | hash automatically becomes a method on the perspective) 95 | 96 | When you render a perspective in a controller, the easiest way is to write: 97 | 98 | ```ruby 99 | class UsersController < ApplicationController 100 | perspectives_actions only: :show # sets up the responder 101 | 102 | def show 103 | user = User.find(param[:id]) 104 | 105 | respond_with(perspective('users/show', user: user)) 106 | end 107 | end 108 | ``` 109 | 110 | The `respond_with` call is what figures out whether we want to return JSON or HTML to the client. 111 | 112 | The default context is just an empty hash; if you want to change that, you can override the 113 | `default_context` method in any controller, e.g.: 114 | 115 | ```ruby 116 | class ApplicationController < ActionController::Base 117 | def default_context 118 | {current_user: current_user} 119 | end 120 | end 121 | ``` 122 | 123 | ### Nested Perspectives 124 | 125 | If you want to render a perspective from another perspective, it's simple! For example: 126 | 127 | ```ruby 128 | class Users::Show < Perspectives::Base 129 | param :user 130 | 131 | property(:name) { user.name } 132 | 133 | nested 'avatar', user: :user 134 | # will render Users::Avatar, passing "user" as a parameter, and make an "avatar" 135 | # method availabe in the mustache template 136 | end 137 | ``` 138 | 139 | ```mustache 140 |
141 | {{{avatar}}} 142 | {{name}} 143 |
144 | ``` 145 | 146 | What about rendering a collection? Also simple! 147 | 148 | ```ruby 149 | class Projects::Show < Perspectives::Base 150 | property :project 151 | 152 | property(:title) { project.title } 153 | 154 | nested_collection 'tasks/show', 155 | collection: proc { project.tasks }, 156 | property: :tasks 157 | 158 | # makes a "tasks" property available which is the list of tasks 159 | end 160 | ``` 161 | 162 | ```mustache 163 |

{{title}}

164 | {{{tasks}}} 165 | ``` 166 | 167 | ### Macros 168 | 169 | Perspectives also provides some nice macros to remove repeat code. For example, 170 | `delegate_property` exposes a method from an object as a property: 171 | 172 | ```ruby 173 | class Users::Show < Perspectives::Base 174 | param :user 175 | delegate_property :name, :email, to: :user 176 | # makes name, email properties available 177 | end 178 | ``` 179 | 180 | ### Caching 181 | 182 | Since Perspectives know about their dependent Perspectives via the `nested` and 183 | `nested_collection` macros above, russian doll caching is trivial. To set that up, 184 | just write: 185 | 186 | ```ruby 187 | class Users::Show < Perspectives::Base 188 | cache { user } # uses "user" as the cache key 189 | end 190 | ``` 191 | 192 | The Perspective cache will expire if the `user` changes, OR if the `users.mustache` template 193 | changes, OR if the `Users::Show` perspective changes. (or if any `nested` Perspective changes) 194 | 195 | ### Client javascript 196 | 197 | Perspectives has basically the same javascript API as [PJAX](https://github.com/defunkt/jquery-pjax), 198 | and adds this line automatically to application.js if you use the `rails g perspectives:install`: 199 | 200 | ```javascript 201 | $(function() { $(document).perspectives('a', 'body') }) 202 | ``` 203 | 204 | That line says "intercept every click on 'a' tags", and request Perspectives JSON from 205 | the server. Then render the resulting template, and replace the content of `$('body')` with the 206 | result of rendering. If you want to use a different container, you could do something like: 207 | 208 | ```javascript 209 | $(function() { $(document).perspectives('a', '#mycontainer') }) 210 | ``` 211 | 212 | which would replace `$('#mycontainer')` instead of `$('body')`. If you did that, you would probably 213 | also want a line like this in your `application_controller.rb`: 214 | 215 | ```ruby 216 | layout lambda { |controller| !controller.request.xhr? && 'application' } 217 | ``` 218 | 219 | which will not render the layout at all if the request is made via xhr. 220 | 221 | ### Render into different containers 222 | 223 | If you want to render a response into a container other than the default you set up, you can set 224 | `'data-perspectives-target'` on an 'a' tag or a form. For example: 225 | 226 | ```html 227 | Andrew Warner 228 | ``` 229 | 230 | Which will render the response into the `$('#viewing-user')` element. This might remind you of the PJAX 231 | API. 232 | 233 | You can also set `data-perspectives-target` on a form, which will render the response from the server 234 | into the target element on `ajax:success`. 235 | 236 | ### Events 237 | 238 | More events TK, but, when perspectives receives a JSON response from the server, it triggers an event 239 | on the element (usually an anchor tag) which triggered the request, called "perspectives:response" 240 | 241 | You can listen to this event and handle it as follows: 242 | 243 | ```javascript 244 | $('a').on('perspectives:response', function(e, options) { 245 | // options has keys: 246 | // json: (the json response) 247 | // status: (response status) 248 | // xhr: (the xhr), 249 | // href: (requested href) 250 | // container: (the rendering container) 251 | 252 | // the default behavior of this event is 253 | Perspectives.renderResponse(options) 254 | 255 | // but you can do whatever you want 256 | // (don't forget to stopPropagation if you don't want the 257 | // default behavior to occur) 258 | }) 259 | ``` 260 | 261 | Perspectives also listens to the `'ajax:success'` event on forms, and renders the response from 262 | the server. 263 | 264 | ### Assets version 265 | 266 | Just like PJAX, Perspectives should re-render the entire page if the assets have changed in some 267 | material way. If you just deployed your site, for example, we want to force everyone to reload the 268 | entire page! 269 | 270 | To configure asset checking, just add the following to your application layout: 271 | 272 | ```erb 273 | <%= assets_meta_tag %> 274 | ``` 275 | 276 | If you're using Rails, Perspectives will set a response header which is the mtime of the most 277 | recently updated asset file. Perspectives will do a full page reload if the assets have changed. 278 | 279 | ### More examples please! 280 | 281 | For a full example app, check out [Rails Genius](https://github.com/RapGenius/railsgenius), 282 | an app that I built to demonstrate Perspectives for Rails Conf. Rails Genius allows you to read 283 | and write inline annotations on RailsConf talk abstracts. 284 | 285 | (just like it's older sibling, [Rap Genius](http://rapgenius.com)) 286 | 287 | ### Ruby version/framework support 288 | 289 | Right now, the easiest way to use perspectives is with Rails 3.2+ / Rails 4, and Ruby 1.9.3+. 290 | 291 | In theory, it should work with Rails 2, although that's not tested, and you have to do some more 292 | work to set everything up. For setup stuff, check out `lib/perspectives/railtie.rb` to see what gets 293 | set up in later version of Rails. The other big different is that, in Rails 2, you'll want to use 294 | `respond_to` instead of `respond_with` (although that part should "just work") 295 | 296 | ## Other benefits 297 | 298 | Besides shared rendering environments between the client and server, and easy-to-implement russian doll caching, 299 | Perspectives also force you to write views "the right way." Views in Perspectives world have a nice separation 300 | of concerns, where Mustache templates deal simply with laying out data in markup, and Perspectives deal with 301 | your business logic. 302 | 303 | This separation of concerns makes testing a whole lot easier than testing in ERB land, since Perspectives are 304 | just ruby objects. While you'd have to render an ERB template and inspect its output in order to testing it, 305 | Perspectives can just be created and individual logic sections tested. 306 | 307 | ## Philosophy 308 | 309 | The core idea behind perspectives if that, if we use Mustache templates for templating, we can 310 | render them either on the client or on the server. We can break the typical Rails ERB/HAML views 311 | into one template, written in Mustache, which doesn't allow arbitrary code, and a "perspective" object, 312 | written in Ruby, which holds the logic needed to generate a hash which can be used to render a Mustache 313 | template. 314 | 315 | Since the Mustache template never communicates directly with the perspective, when a client makes a 316 | request to our site, we can build the hash of properties with a perspective, and then either render it 317 | on the server in the case that the client is a web crawler or a user visiting the page for the first time, 318 | OR, in the case that the client already has a browser instance loaded up, we can simply return the JSON 319 | hash to the client and let them render or update the page however they want. 320 | 321 | If the user already has a page on the site loaded, then serving the JSON necessary to render a template 322 | is much better than rendering it on the server and sending back an HTML fragment. If the server sends back 323 | HTML, and the client wants to do something besides immediately render, then it would have to inspect the HTML 324 | fragment from the response and yank out the information it wants. HTML is too brittle to rely upon for those 325 | purposes! Instead, forcing the separation between the data needed to render a template and the layout/markup 326 | in the template itself means that we're automatically building a JSON API as we're building out our site. 327 | 328 | ## TODO 329 | 330 | There are some key things that are needed in order to make this library TRULY shine. The main thing is an 331 | easy-to-use javascript library on the client that can be used to create client-only behavior. (such as transitioning 332 | between pages, client-only behavior, etc) The ideally integration would be with some kind of existing library 333 | like backbone.js, ember.js, or angular.js. With a front end "shell" over the client-side rendering side of this, we 334 | could easily add client-only features without duplicating views and other business logic in the browser. 335 | 336 | ## License 337 | 338 | MIT 339 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/mustache-0.8.1.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * mustache.js - Logic-less {{mustache}} templates with JavaScript 3 | * http://github.com/janl/mustache.js 4 | */ 5 | 6 | /*global define: false*/ 7 | 8 | (function (root, factory) { 9 | if (typeof exports === "object" && exports) { 10 | factory(exports); // CommonJS 11 | } else { 12 | var mustache = {}; 13 | factory(mustache); 14 | if (typeof define === "function" && define.amd) { 15 | define(mustache); // AMD 16 | } else { 17 | root.Mustache = mustache; //