require "pathname" require "active_support/core_ext/class" require "active_support/core_ext/io" require "active_support/core_ext/string/starts_ends_with" require "action_view/template" module ActionView # = Action View Resolver class Resolver # Keeps all information about view path and builds virtual path. class Path < String attr_reader :name, :prefix, :partial, :virtual alias_method :partial?, :partial def self.build(name, prefix, partial) virtual = "" virtual << "#{prefix}/" unless prefix.empty? virtual << (partial ? "_#{name}" : name) new name, prefix, partial, virtual end def initialize(name, prefix, partial, virtual) @name, @prefix, @partial = name, prefix, partial super(virtual) end end cattr_accessor :caching self.caching = true class << self alias :caching? :caching end def initialize @cached = Hash.new { |h1,k1| h1[k1] = Hash.new { |h2,k2| h2[k2] = Hash.new { |h3,k3| h3[k3] = Hash.new { |h4,k4| h4[k4] = {} } } } } end def clear_cache @cached.clear end # Normalizes the arguments and passes it on to find_template. def find_all(name, prefix=nil, partial=false, details={}, key=nil, locals=[]) cached(key, [name, prefix, partial], details, locals) do find_templates(name, prefix, partial, details, false) end end def find_all_anywhere(name, prefix, partial=false, details={}, key=nil, locals=[]) cached(key, [name, prefix, partial], details, locals) do find_templates(name, prefix, partial, details, true) end end private delegate :caching?, :to => "self.class" # This is what child classes implement. No defaults are needed # because Resolver guarantees that the arguments are present and # normalized. def find_templates(name, prefix, partial, details, outside_app_allowed = false) raise NotImplementedError, "Subclasses must implement a find_templates(name, prefix, partial, details, outside_app_allowed) method" end # Helpers that builds a path. Useful for building virtual paths. def build_path(name, prefix, partial) Path.build(name, prefix, partial) end # Handles templates caching. If a key is given and caching is on # always check the cache before hitting the resolver. Otherwise, # it always hits the resolver but check if the resolver is fresher # before returning it. def cached(key, path_info, details, locals) #:nodoc: name, prefix, partial = path_info locals = locals.map { |x| x.to_s }.sort! if key && caching? @cached[key][name][prefix][partial][locals] ||= decorate(yield, path_info, details, locals) else fresh = decorate(yield, path_info, details, locals) return fresh unless key scope = @cached[key][name][prefix][partial] cache = scope[locals] mtime = cache && cache.map(&:updated_at).max if !mtime || fresh.empty? || fresh.any? { |t| t.updated_at > mtime } scope[locals] = fresh else cache end end end # Ensures all the resolver information is set in the template. def decorate(templates, path_info, details, locals) #:nodoc: cached = nil templates.each do |t| t.locals = locals t.formats = details[:formats] || [:html] if t.formats.empty? t.virtual_path ||= (cached ||= build_path(*path_info)) end end end # An abstract class that implements a Resolver with path semantics. class PathResolver < Resolver #:nodoc: EXTENSIONS = [:locale, :formats, :handlers] DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{.:handlers,}" def initialize(pattern=nil) @pattern = pattern || DEFAULT_PATTERN super() end cattr_accessor :instance_reader => false, :instance_writer => false private def find_templates(name, prefix, partial, details, outside_app_allowed = false) path = Path.build(name, prefix, partial) query(path, details, details[:formats], outside_app_allowed) end def query(path, details, formats, outside_app_allowed) query = build_query(path, details) template_paths = find_template_paths query template_paths = reject_files_external_to_app(template_paths) unless outside_app_allowed template_paths.map { |template| handler, format = extract_handler_and_format(template, formats) contents = File.binread template Template.new(contents, File.expand_path(template), handler, :virtual_path => path.virtual, :format => format, :updated_at => mtime(template)) } end def reject_files_external_to_app(files) files.reject { |filename| !inside_path?(@path, filename) } end if RUBY_VERSION >= '2.2.0' def find_template_paths(query) Dir[query].reject { |filename| File.directory?(filename) || # deals with case-insensitive file systems. !File.fnmatch(query, filename, File::FNM_EXTGLOB) } end else def find_template_paths(query) # deals with case-insensitive file systems. sanitizer = Hash.new { |h,dir| h[dir] = Dir["#{dir}/*"] } Dir[query].reject { |filename| File.directory?(filename) || !sanitizer[File.dirname(filename)].include?(filename) } end end def inside_path?(path, filename) filename = File.expand_path(filename) path = File.join(path, '') filename.start_with?(path) end # Helper for building query glob string based on resolver's pattern. def build_query(path, details) query = @pattern.dup prefix = path.prefix.empty? ? "" : "#{escape_entry(path.prefix)}\\1" query.gsub!(/\:prefix(\/)?/, prefix) partial = escape_entry(path.partial? ? "_#{path.name}" : path.name) query.gsub!(/\:action/, partial) details.each do |ext, variants| query.gsub!(/\:#{ext}/, "{#{variants.compact.uniq.join(',')}}") end File.expand_path(query, @path) end def escape_entry(entry) entry.gsub(/[*?{}\[\]]/, '\\\\\\&') end # Returns the file mtime from the filesystem. def mtime(p) File.mtime(p) end # Extract handler and formats from path. If a format cannot be a found neither # from the path, or the handler, we should return the array of formats given # to the resolver. def extract_handler_and_format(path, default_formats) pieces = File.basename(path).split(".") pieces.shift handler = Template.handler_for_extension(pieces.pop) format = pieces.last && Mime[pieces.last] [handler, format] end end # A resolver that loads files from the filesystem. It allows to set your own # resolving pattern. Such pattern can be a glob string supported by some variables. # # ==== Examples # # Default pattern, loads views the same way as previous versions of rails, eg. when you're # looking for `users/new` it will produce query glob: `users/new{.{en},}{.{html,js},}{.{erb,haml},}` # # FileSystemResolver.new("/path/to/views", ":prefix/:action{.:locale,}{.:formats,}{.:handlers,}") # # This one allows you to keep files with different formats in seperated subdirectories, # eg. `users/new.html` will be loaded from `users/html/new.erb` or `users/new.html.erb`, # `users/new.js` from `users/js/new.erb` or `users/new.js.erb`, etc. # # FileSystemResolver.new("/path/to/views", ":prefix/{:formats/,}:action{.:locale,}{.:formats,}{.:handlers,}") # # If you don't specify pattern then the default will be used. # # In order to use any of the customized resolvers above in a Rails application, you just need # to configure ActionController::Base.view_paths in an initializer, for example: # # ActionController::Base.view_paths = FileSystemResolver.new( # Rails.root.join("app/views"), # ":prefix{/:locale}/:action{.:formats,}{.:handlers,}" # ) # # ==== Pattern format and variables # # Pattern have to be a valid glob string, and it allows you to use the # following variables: # # * :prefix - usualy the controller path # * :action - name of the action # * :locale - possible locale versions # * :formats - possible request formats (for example html, json, xml...) # * :handlers - possible handlers (for example erb, haml, builder...) # class FileSystemResolver < PathResolver def initialize(path, pattern=nil) raise ArgumentError, "path already is a Resolver class" if path.is_a?(Resolver) super(pattern) @path = File.expand_path(path) end def to_s @path.to_s end alias :to_path :to_s def eql?(resolver) self.class.equal?(resolver.class) && to_path == resolver.to_path end alias :== :eql? end # An Optimized resolver for Rails' most common case. class OptimizedFileSystemResolver < FileSystemResolver #:nodoc: def build_query(path, details) exts = EXTENSIONS.map { |ext| details[ext] } if path.to_s.starts_with? @path.to_s query = escape_entry(path) else query = escape_entry(File.join(@path, path)) end query + exts.map { |ext| "{#{ext.compact.uniq.map { |e| ".#{e}," }.join}}" }.join end end # The same as FileSystemResolver but does not allow templates to store # a virtual path since it is invalid for such resolvers. class FallbackFileSystemResolver < FileSystemResolver #:nodoc: def self.instances [new(""), new("/")] end def decorate(*) super.each { |t| t.virtual_path = nil } end end end