lib/frank/tilt.rb in frank-0.2.6 vs lib/frank/tilt.rb in frank-0.3.0.beta

- old
+ new

@@ -1,54 +1,67 @@ +require 'digest/md5' + module Tilt - VERSION = '0.5' + VERSION = '0.9' @template_mappings = {} - # Hash of template path pattern => template implementation - # class mappings. + # Hash of template path pattern => template implementation class mappings. def self.mappings @template_mappings end # Register a template implementation by file extension. def self.register(ext, template_class) ext = ext.to_s.sub(/^\./, '') mappings[ext.downcase] = template_class end + # Returns true when a template exists on an exact match of the provided file extension + def self.registered?(ext) + mappings.key?(ext.downcase) + end + # Create a new template for the given file using the file's extension # to determine the the template mapping. def self.new(file, line=nil, options={}, &block) if template_class = self[file] template_class.new(file, line, options, &block) else fail "No template engine registered for #{File.basename(file)}" end end - # Lookup a template class given for the given filename or file + # Lookup a template class for the given filename or file # extension. Return nil when no implementation is found. def self.[](file) - if @template_mappings.key?(pattern = file.to_s.downcase) - @template_mappings[pattern] - elsif @template_mappings.key?(pattern = File.basename(pattern)) - @template_mappings[pattern] - else - while !pattern.empty? - if @template_mappings.key?(pattern) - return @template_mappings[pattern] - else - pattern = pattern.sub(/^[^.]*\.?/, '') - end - end - nil + pattern = file.to_s.downcase + unless registered?(pattern) + pattern = File.basename(pattern) + pattern.sub!(/^[^.]*\.?/, '') until (pattern.empty? || registered?(pattern)) end + @template_mappings[pattern] end + # Mixin allowing template compilation on scope objects. + # + # Including this module in scope objects passed to Template#render + # causes template source to be compiled to methods the first time they're + # used. This can yield significant (5x-10x) performance increases for + # templates that support it (ERB, Erubis, Builder). + # + # It's also possible (though not recommended) to include this module in + # Object to enable template compilation globally. The downside is that + # the template methods will polute the global namespace and could lead to + # unexpected behavior. + module CompileSite + def __tilt__ + end + end # Base class for template implementations. Subclasses must implement - # the #compile! method and one of the #evaluate or #template_source + # the #prepare method and one of the #evaluate or #template_source # methods. class Template # Template source; loaded from a file or given directly. attr_reader :data @@ -61,55 +74,64 @@ # A Hash of template engine specific options. This is passed directly # to the underlying engine and is not used by the generic template # interface. attr_reader :options + # Used to determine if this class's initialize_engine method has + # been called yet. + @engine_initialized = false + class << self + attr_accessor :engine_initialized + alias engine_initialized? engine_initialized + end + # Create a new template with the file, line, and options specified. By - # default, template data is read from the file specified. When a block - # is given, it should read template data and return as a String. When - # file is nil, a block is required. + # default, template data is read from the file. When a block is given, + # it should read template data and return as a String. When file is nil, + # a block is required. # - # The #initialize_engine method is called if this is the very first - # time this template subclass has been initialized. + # All arguments are optional. def initialize(file=nil, line=1, options={}, &block) - raise ArgumentError, "file or block required" if file.nil? && block.nil? - options, line = line, 1 if line.is_a?(Hash) - @file = file - @line = line || 1 - @options = options || {} - @reader = block || lambda { |t| File.read(file) } + @file, @line, @options = nil, 1, {} - if !self.class.engine_initialized + [options, line, file].compact.each do |arg| + case + when arg.respond_to?(:to_str) ; @file = arg.to_str + when arg.respond_to?(:to_int) ; @line = arg.to_int + when arg.respond_to?(:to_hash) ; @options = arg.to_hash.dup + else raise TypeError + end + end + + raise ArgumentError, "file or block required" if (@file || block).nil? + + # call the initialize_engine method if this is the very first time + # an instance of this class has been created. + if !self.class.engine_initialized? initialize_engine self.class.engine_initialized = true end - end - # Called once and only once for each template subclass the first time - # the template class is initialized. This should be used to require the - # underlying template library and perform any initial setup. - def initialize_engine - end - @engine_initialized = false - class << self ; attr_accessor :engine_initialized ; end + # used to generate unique method names for template compilation + @stamp = (Time.now.to_f * 10000).to_i + @compiled_method_names = {} - - # Load template source and compile the template. The template is - # loaded and compiled the first time this method is called; subsequent - # calls are no-ops. - def compile - if @data.nil? - @data = @reader.call(self) - compile! + # load template data and prepare + if @file.match(/^[^\n]+$/) && File.exist?(@file) + @reader = block || lambda { |t| File.read(@file) } + else + @reader = block || lambda { |t| @file } end + + @data = @reader.call(self) + prepare end # Render the template in the given scope with the locals specified. If a # block is given, it is typically available within the template via # +yield+. def render(scope=Object.new, locals={}, &block) - compile evaluate scope, locals || {}, &block end # The basename of the template file. def basename(suffix='') @@ -125,57 +147,181 @@ def eval_file file || '(__TEMPLATE__)' end protected - # Do whatever preparation is necessary to "compile" the template. - # Called immediately after template #data is loaded. Instance variables - # set in this method are available when #evaluate is called. + # Called once and only once for each template subclass the first time + # the template class is initialized. This should be used to require the + # underlying template library and perform any initial setup. + def initialize_engine + end + + # Like Kernel::require but issues a warning urging a manual require when + # running under a threaded environment. + def require_template_library(name) + # if Thread.list.size > 1 + # warn "WARN: tilt autoloading '#{name}' in a non thread-safe way; " + + # "explicit require '#{name}' suggested." + # end + require name + end + + # Do whatever preparation is necessary to setup the underlying template + # engine. Called immediately after template data is loaded. Instance + # variables set in this method are available when #evaluate is called. # # Subclasses must provide an implementation of this method. - def compile! - raise NotImplementedError + def prepare + if respond_to?(:compile!) + # backward compat with tilt < 0.6; just in case + warn 'Tilt::Template#compile! is deprecated; implement #prepare instead.' + compile! + else + raise NotImplementedError + end end - # Process the template and return the result. Subclasses should override - # this method unless they implement the #template_source. + # Process the template and return the result. When the scope mixes in + # the Tilt::CompileSite module, the template is compiled to a method and + # reused given identical locals keys. When the scope object + # does not mix in the CompileSite module, the template source is + # evaluated with instance_eval. In any case, template executation + # is guaranteed to be performed in the scope object with the locals + # specified and with support for yielding to the block. def evaluate(scope, locals, &block) - source, offset = local_assignment_code(locals) - source = [source, template_source].join("\n") - scope.instance_eval source, eval_file, line - offset + if scope.respond_to?(:__tilt__) + method_name = compiled_method_name(locals.keys) + if scope.respond_to?(method_name) + scope.send(method_name, locals, &block) + else + compile_template_method(method_name, locals) + scope.send(method_name, locals, &block) + end + else + evaluate_source(scope, locals, &block) + end end - # Return a string containing the (Ruby) source code for the template. The - # default Template#evaluate implementation requires this method be - # defined. - def template_source + # Generates all template source by combining the preamble, template, and + # postamble and returns a two-tuple of the form: [source, offset], where + # source is the string containing (Ruby) source code for the template and + # offset is the integer line offset where line reporting should begin. + # + # Template subclasses may override this method when they need complete + # control over source generation or want to adjust the default line + # offset. In most cases, overriding the #precompiled_template method is + # easier and more appropriate. + def precompiled(locals) + preamble = precompiled_preamble(locals) + parts = [ + preamble, + precompiled_template(locals), + precompiled_postamble(locals) + ] + [parts.join("\n"), preamble.count("\n") + 1] + end + + # A string containing the (Ruby) source code for the template. The + # default Template#evaluate implementation requires either this method + # or the #precompiled method be overridden. When defined, the base + # Template guarantees correct file/line handling, locals support, custom + # scopes, and support for template compilation when the scope object + # allows it. + def precompiled_template(locals) raise NotImplementedError end + # Generates preamble code for initializing template state, and performing + # locals assignment. The default implementation performs locals + # assignment only. Lines included in the preamble are subtracted from the + # source line offset, so adding code to the preamble does not effect line + # reporting in Kernel::caller and backtraces. + def precompiled_preamble(locals) + locals.map { |k,v| "#{k} = locals[:#{k}]" }.join("\n") + end + + # Generates postamble code for the precompiled template source. The + # string returned from this method is appended to the precompiled + # template source. + def precompiled_postamble(locals) + '' + end + + # The unique compiled method name for the locals keys provided. + def compiled_method_name(locals_keys) + @compiled_method_names[locals_keys] ||= + generate_compiled_method_name(locals_keys) + end + private - def local_assignment_code(locals) - return ['', 1] if locals.empty? - source = locals.collect { |k,v| "#{k} = locals[:#{k}]" } - [source.join("\n"), source.length] + # Evaluate the template source in the context of the scope object. + def evaluate_source(scope, locals, &block) + source, offset = precompiled(locals) + scope.instance_eval(source, eval_file, line - offset) end - def require_template_library(name) - if Thread.list.size > 1 - warn "WARN: tilt autoloading '#{name}' in a non thread-safe way; " + - "explicit require '#{name}' suggested." + # JRuby doesn't allow Object#instance_eval to yield to the block it's + # closed over. This is by design and (ostensibly) something that will + # change in MRI, though no current MRI version tested (1.8.6 - 1.9.2) + # exhibits the behavior. More info here: + # + # http://jira.codehaus.org/browse/JRUBY-2599 + # + # Additionally, JRuby's eval line reporting is off by one compared to + # all MRI versions tested. + # + # We redefine evaluate_source to work around both issues. + if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' + undef evaluate_source + def evaluate_source(scope, locals, &block) + source, offset = precompiled(locals) + file, lineno = eval_file, (line - offset) - 1 + scope.instance_eval { Kernel::eval(source, binding, file, lineno) } end - require name end + + def generate_compiled_method_name(locals_keys) + parts = [object_id, @stamp] + locals_keys.map { |k| k.to_s }.sort + digest = Digest::MD5.hexdigest(parts.join(':')) + "__tilt_#{digest}" + end + + def compile_template_method(method_name, locals) + source, offset = precompiled(locals) + offset += 1 + CompileSite.module_eval <<-RUBY, eval_file, line - offset + def #{method_name}(locals) + #{source} + end + RUBY + + ObjectSpace.define_finalizer self, + Template.compiled_template_method_remover(CompileSite, method_name) + end + + def self.compiled_template_method_remover(site, method_name) + proc { |oid| garbage_collect_compiled_template_method(site, method_name) } + end + + def self.garbage_collect_compiled_template_method(site, method_name) + site.module_eval do + begin + remove_method(method_name) + rescue NameError + # method was already removed (ruby >= 1.9) + end + end + end end # Extremely simple template cache implementation. Calling applications # create a Tilt::Cache instance and use #fetch with any set of hashable # arguments (such as those to Tilt.new): # cache = Tilt::Cache.new # cache.fetch(path, line, options) { Tilt.new(path, line, options) } # - # Subsequent invocations return the already compiled template object. + # Subsequent invocations return the already loaded template object. class Cache def initialize @cache = {} end @@ -193,84 +339,106 @@ # The template source is evaluated as a Ruby string. The #{} interpolation # syntax can be used to generated dynamic output. class StringTemplate < Template - def compile! + def prepare @code = "%Q{#{data}}" end - def template_source + def precompiled_template(locals) @code end end register 'str', StringTemplate # ERB template implementation. See: # http://www.ruby-doc.org/stdlib/libdoc/erb/rdoc/classes/ERB.html class ERBTemplate < Template def initialize_engine - require_template_library 'erb' unless defined? ::ERB + return if defined? ::ERB + require_template_library 'erb' end - def compile! - @engine = ::ERB.new(data, options[:safe], options[:trim], '@_out_buf') + def prepare + @outvar = (options[:outvar] || '_erbout').to_s + @engine = ::ERB.new(data, options[:safe], options[:trim], @outvar) end - def template_source + def precompiled_template(locals) @engine.src end - def evaluate(scope, locals, &block) - source, offset = local_assignment_code(locals) - source = [source, template_source].join("\n") + def precompiled_preamble(locals) + <<-RUBY + begin + __original_outvar = #{@outvar} if defined?(#{@outvar}) + #{super} + RUBY + end - original_out_buf = - scope.instance_variables.any? { |var| var.to_sym == :@_out_buf } && - scope.instance_variable_get(:@_out_buf) - - scope.instance_eval source, eval_file, line - offset - - output = scope.instance_variable_get(:@_out_buf) - scope.instance_variable_set(:@_out_buf, original_out_buf) - - output + def precompiled_postamble(locals) + <<-RUBY + #{super} + ensure + #{@outvar} = __original_outvar + end + RUBY end - private - # ERB generates a line to specify the character coding of the generated # source in 1.9. Account for this in the line offset. if RUBY_VERSION >= '1.9.0' - def local_assignment_code(locals) + def precompiled(locals) source, offset = super [source, offset + 1] end end end + %w[erb rhtml].each { |ext| register ext, ERBTemplate } # Erubis template implementation. See: # http://www.kuwata-lab.com/erubis/ + # + # ErubisTemplate supports the following additional options, which are not + # passed down to the Erubis engine: + # + # :engine_class allows you to specify a custom engine class to use + # instead of the default (which is ::Erubis::Eruby). + # + # :escape_html when true, ::Erubis::EscapedEruby will be used as + # the engine class instead of the default. All content + # within <%= %> blocks will be automatically html escaped. class ErubisTemplate < ERBTemplate def initialize_engine - require_template_library 'erubis' unless defined? ::Erubis + return if defined? ::Erubis + require_template_library 'erubis' end - def compile! - Erubis::Eruby.class_eval(%Q{def add_preamble(src) src << "@_out_buf = _buf = '';" end}) - @engine = ::Erubis::Eruby.new(data, options) + def prepare + @options.merge!(:preamble => false, :postamble => false) + @outvar = (options.delete(:outvar) || '_erbout').to_s + engine_class = options.delete(:engine_class) + engine_class = ::Erubis::EscapedEruby if options.delete(:escape_html) + @engine = (engine_class || ::Erubis::Eruby).new(data, options) end - private + def precompiled_preamble(locals) + [super, "#{@outvar} = _buf = ''"].join("\n") + end - # Erubis doesn't have ERB's line-off-by-one under 1.9 problem. Override - # and adjust back. + def precompiled_postamble(locals) + ["_buf", super].join("\n") + end + + # Erubis doesn't have ERB's line-off-by-one under 1.9 problem. + # Override and adjust back. if RUBY_VERSION >= '1.9.0' - def local_assignment_code(locals) + def precompiled(locals) source, offset = super [source, offset - 1] end end end @@ -279,44 +447,79 @@ # Haml template implementation. See: # http://haml.hamptoncatlin.com/ class HamlTemplate < Template def initialize_engine - require_template_library 'haml' unless defined? ::Haml::Engine + return if defined? ::Haml::Engine + require_template_library 'haml' end - def compile! - @engine = ::Haml::Engine.new(data, haml_options) + def prepare + options = @options.merge(:filename => eval_file, :line => line) + @engine = ::Haml::Engine.new(data, options) end def evaluate(scope, locals, &block) - @engine.render(scope, locals, &block) + if @engine.respond_to?(:precompiled_method_return_value, true) + super + else + @engine.render(scope, locals, &block) + end end - private - def haml_options - options.merge(:filename => eval_file, :line => line) + # Precompiled Haml source. Taken from the precompiled_with_ambles + # method in Haml::Precompiler: + # http://github.com/nex3/haml/blob/master/lib/haml/precompiler.rb#L111-126 + def precompiled_template(locals) + @engine.precompiled end + + def precompiled_preamble(locals) + local_assigns = super + @engine.instance_eval do + <<-RUBY + begin + extend Haml::Helpers + _hamlout = @haml_buffer = Haml::Buffer.new(@haml_buffer, #{options_for_buffer.inspect}) + _erbout = _hamlout.buffer + __in_erb_template = true + _haml_locals = locals + #{local_assigns} + RUBY + end + end + + def precompiled_postamble(locals) + @engine.instance_eval do + <<-RUBY + #{precompiled_method_return_value} + ensure + @haml_buffer = @haml_buffer.upper + end + RUBY + end + end end register 'haml', HamlTemplate # Sass template implementation. See: # http://haml.hamptoncatlin.com/ # # Sass templates do not support object scopes, locals, or yield. class SassTemplate < Template def initialize_engine - require_template_library 'sass' unless defined? ::Sass::Engine + return if defined? ::Sass::Engine + require_template_library 'sass' end - def compile! + def prepare @engine = ::Sass::Engine.new(data, sass_options) end def evaluate(scope, locals, &block) - @engine.render + @output ||= @engine.render end private def sass_options options.merge(:filename => eval_file, :line => line) @@ -329,31 +532,34 @@ # http://lesscss.org/ # # Less templates do not support object scopes, locals, or yield. class LessTemplate < Template def initialize_engine - require_template_library 'less' unless defined? ::Less::Engine + return if defined? ::Less::Engine + require_template_library 'less' end - def compile! + def prepare @engine = ::Less::Engine.new(data) end def evaluate(scope, locals, &block) @engine.to_css end end register 'less', LessTemplate + # Builder template implementation. See: # http://builder.rubyforge.org/ class BuilderTemplate < Template def initialize_engine - require_template_library 'builder' unless defined?(::Builder) + return if defined?(::Builder) + require_template_library 'builder' end - def compile! + def prepare end def evaluate(scope, locals, &block) xml = ::Builder::XmlMarkup.new(:indent => 2) if data.respond_to?(:to_str) @@ -363,11 +569,11 @@ data.call(xml) end xml.target! end - def template_source + def precompiled_template(locals) data.to_str end end register 'builder', BuilderTemplate @@ -385,26 +591,26 @@ # # It's suggested that your program require 'liquid' at load # time when using this template engine. class LiquidTemplate < Template def initialize_engine - require_template_library 'liquid' unless defined? ::Liquid::Template + return if defined? ::Liquid::Template + require_template_library 'liquid' end - def compile! + def prepare @engine = ::Liquid::Template.parse(data) end def evaluate(scope, locals, &block) locals = locals.inject({}){ |h,(k,v)| h[k.to_s] = v ; h } if scope.respond_to?(:to_h) scope = scope.to_h.inject({}){ |h,(k,v)| h[k.to_s] = v ; h } locals = scope.merge(locals) end - # TODO: Is it possible to lazy yield ? locals['yield'] = block.nil? ? '' : yield - locals['content'] = block.nil? ? '' : yield + locals['content'] = locals['yield'] @engine.render(locals) end end register 'liquid', LiquidTemplate @@ -419,19 +625,21 @@ def flags [:smart, :filter_html].select { |flag| options[flag] } end def initialize_engine - require_template_library 'rdiscount' unless defined? ::RDiscount + return if defined? ::RDiscount + require_template_library 'rdiscount' end - def compile! + def prepare @engine = RDiscount.new(data, *flags) + @output = nil end def evaluate(scope, locals, &block) - @engine.to_html + @output ||= @engine.to_html end end register 'markdown', RDiscountTemplate register 'mkd', RDiscountTemplate register 'md', RDiscountTemplate @@ -439,19 +647,21 @@ # RedCloth implementation. See: # http://redcloth.org/ class RedClothTemplate < Template def initialize_engine - require_template_library 'redcloth' unless defined? ::RedCloth + return if defined? ::RedCloth + require_template_library 'redcloth' end - def compile! + def prepare @engine = RedCloth.new(data) + @output = nil end def evaluate(scope, locals, &block) - @engine.to_html + @output ||= @engine.to_html end end register 'textile', RedClothTemplate @@ -463,18 +673,20 @@ # view. class MustacheTemplate < Template attr_reader :engine def initialize_engine - require_template_library 'mustache' unless defined? ::Mustache + return if defined? ::Mustache + require_template_library 'mustache' end - def compile! + def prepare Mustache.view_namespace = options[:namespace] + Mustache.view_path = options[:view_path] || options[:mustaches] @engine = options[:view] || Mustache.view_class(name) options.each do |key, value| - next if %w[view namespace mustaches].include?(key.to_s) + next if %w[view view_path namespace mustaches].include?(key.to_s) @engine.send("#{key}=", value) if @engine.respond_to? "#{key}=" end end def evaluate(scope=nil, locals={}, &block) @@ -499,47 +711,83 @@ instance.to_html end end register 'mustache', MustacheTemplate + # RDoc template. See: # http://rdoc.rubyforge.org/ # # It's suggested that your program require 'rdoc/markup' and # 'rdoc/markup/to_html' at load time when using this template # engine. class RDocTemplate < Template def initialize_engine - unless defined?(::RDoc::Markup) - require_template_library 'rdoc/markup' - require_template_library 'rdoc/markup/to_html' - end + return if defined?(::RDoc::Markup) + require_template_library 'rdoc/markup' + require_template_library 'rdoc/markup/to_html' end - def compile! + def prepare markup = RDoc::Markup::ToHtml.new @engine = markup.convert(data) + @output = nil end def evaluate(scope, locals, &block) - @engine.to_s + @output ||= @engine.to_s end end register 'rdoc', RDocTemplate - + + # CoffeeScript info: # http://jashkenas.github.com/coffee-script/ class CoffeeTemplate < Template def initialize_engine - require_template_library 'coffee-script' unless defined? ::CoffeeScript + return if defined? ::CoffeeScript + require_template_library 'coffee-script' end - def compile! - @engine = ::CoffeeScript::compile(data, options) + def prepare + @output = nil end def evaluate(scope, locals, &block) - @engine + @output ||= ::CoffeeScript::compile(data, options) end end register 'coffee', CoffeeTemplate + + # Radius Template + # http://github.com/jlong/radius/ + class RadiusTemplate < Template + def initialize_engine + return if defined? ::Radius + require_template_library 'radius' + end + + def prepare + @context = Class.new(Radius::Context).new + end + + def evaluate(scope, locals, &block) + @context.define_tag("yield") do + block.call + end + (class << @context; self; end).class_eval do + define_method :tag_missing do |tag, attr, &block| + if locals.key?(tag.to_sym) + locals[tag.to_sym] + else + scope.__send__(tag) # any way to support attr as args? + end + end + end + # TODO: how to config tag prefix? + parser = Radius::Parser.new(@context, :tag_prefix => 'r') + parser.parse(data) + end + end + register 'radius', RadiusTemplate + end