lib/tilt/template.rb in tilt-2.1.0 vs lib/tilt/template.rb in tilt-2.2.0

- old
+ new

@@ -1,5 +1,6 @@ +# frozen_string_literal: true module Tilt # @private module CompiledTemplates end @@ -38,16 +39,16 @@ # metadata. def metadata @metadata ||= {} end - # @deprecated Use `.metadata[:mime_type]` instead. + # Use `.metadata[:mime_type]` instead. def default_mime_type metadata[:mime_type] end - # @deprecated Use `.metadata[:mime_type] = val` instead. + # Use `.metadata[:mime_type] = val` instead. def default_mime_type=(value) metadata[:mime_type] = value end end @@ -55,37 +56,32 @@ # 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. # # All arguments are optional. - def initialize(file=nil, line=1, options={}, &block) - @file, @line, @options = nil, 1, {} + def initialize(file=nil, line=nil, options=nil) + @file, @line, @options = nil, 1, nil - [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 - when arg.respond_to?(:path) ; @file = arg.path - when arg.respond_to?(:to_path) ; @file = arg.to_path - else raise TypeError, "Can't load the template file. Pass a string with a path " + - "or an object that responds to 'to_str', 'path' or 'to_path'" - end - end + process_arg(options) + process_arg(line) + process_arg(file) - raise ArgumentError, "file or block required" if (@file || block).nil? + raise ArgumentError, "file or block required" unless @file || block_given? - # used to hold compiled template methods - @compiled_method = {} + @options ||= {} - # used on 1.9 to set the encoding if it is not set elsewhere (like a magic comment) - # currently only used if template compiles to ruby + set_compiled_method_cache + + # Force the encoding of the input data @default_encoding = @options.delete :default_encoding + # Skip encoding detection from magic comments and forcing that encoding + # for compiled templates + @skip_compiled_encoding_detection = @options.delete :skip_compiled_encoding_detection + # load template data and prepare (uses binread to avoid encoding issues) - @reader = block || lambda { |t| read_template_file } - @data = @reader.call(self) + @data = block_given? ? yield(self) : read_template_file if @data.respond_to?(:force_encoding) if default_encoding @data = @data.dup if @data.frozen? @data.force_encoding(default_encoding) @@ -100,32 +96,33 @@ 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=nil, locals={}, &block) - scope ||= Object.new + def render(scope=nil, locals=nil, &block) current_template = Thread.current[:tilt_current_template] Thread.current[:tilt_current_template] = self - evaluate(scope, locals || {}, &block) + evaluate(scope || Object.new, locals || EMPTY_HASH, &block) ensure Thread.current[:tilt_current_template] = current_template end # The basename of the template file. def basename(suffix='') - File.basename(file, suffix) if file + File.basename(@file, suffix) if @file end # The template file's basename with all extensions chomped off. def name - basename.split('.', 2).first if basename + if bname = basename + bname.split('.', 2).first + end end # The filename used in backtraces to describe the template. def eval_file - file || '(__TEMPLATE__)' + @file || '(__TEMPLATE__)' end # An empty Hash that the template engine can populate with various # metadata. def metadata @@ -145,27 +142,48 @@ path = File.expand_path(path.sub(/\.rb\z/i, '')) end @compiled_path = path end + # The compiled method for the locals keys and scope_class provided. + # Returns an UnboundMethod, which can be used to define methods + # directly on the scope class, which are much faster to call than + # Tilt's normal rendering. + def compiled_method(locals_keys, scope_class=nil) + key = [scope_class, locals_keys].freeze + LOCK.synchronize do + if meth = @compiled_method[key] + return meth + end + end + meth = compile_template_method(locals_keys, scope_class) + LOCK.synchronize do + @compiled_method[key] = meth + end + meth + end + protected # @!group For template implementations # The encoding of the source data. Defaults to the # default_encoding-option if present. You may override this method # in your template class if you have a better hint of the data's # encoding. attr_reader :default_encoding + def skip_compiled_encoding_detection? + @skip_compiled_encoding_detection + 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. + # Empty by default as some subclasses do not need separate preparation. def prepare - raise NotImplementedError end CLASS_METHOD = Kernel.instance_method(:class) USE_BIND_CALL = RUBY_VERSION >= '2.7' @@ -181,18 +199,22 @@ case scope when Object scope_class = Module === scope ? scope : scope.class else + # :nocov: scope_class = USE_BIND_CALL ? CLASS_METHOD.bind_call(scope) : CLASS_METHOD.bind(scope).call + # :nocov: end method = compiled_method(locals_keys, scope_class) if USE_BIND_CALL method.bind_call(scope, locals, &block) + # :nocov: else method.bind(scope).call(locals, &block) + # :nocov: end end # Generates all template source by combining the preamble, template, and # postamble and returns a two-tuple of the form: [source, offset], where @@ -207,19 +229,23 @@ preamble = precompiled_preamble(local_keys) template = precompiled_template(local_keys) postamble = precompiled_postamble(local_keys) source = String.new - # Ensure that our generated source code has the same encoding as the - # the source code generated by the template engine. - if source.respond_to?(:force_encoding) - template_encoding = extract_encoding(template) + unless skip_compiled_encoding_detection? + # Ensure that our generated source code has the same encoding as the + # the source code generated by the template engine. + template_encoding = extract_encoding(template){|t| template = t} - source.force_encoding(template_encoding) - template.force_encoding(template_encoding) + if template.encoding != template_encoding + # template should never be frozen here. If it was frozen originally, + # then extract_encoding should yield a dup. + template.force_encoding(template_encoding) + end end + source.force_encoding(template.encoding) source << preamble << "\n" << template << "\n" << postamble [source, preamble.count("\n")+1] end @@ -243,47 +269,66 @@ # !@endgroup private - def read_template_file - data = File.open(file, 'rb') { |io| io.read } - if data.respond_to?(:force_encoding) - # Set it to the default external (without verifying) - data.force_encoding(Encoding.default_external) if Encoding.default_external + def process_arg(arg) + if 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 + when arg.respond_to?(:path) ; @file = arg.path + when arg.respond_to?(:to_path) ; @file = arg.to_path + else raise TypeError, "Can't load the template file. Pass a string with a path " + + "or an object that responds to 'to_str', 'path' or 'to_path'" + end end + end + + def read_template_file + data = File.binread(file) + # Set it to the default external (without verifying) + # :nocov: + data.force_encoding(Encoding.default_external) if Encoding.default_external + # :nocov: data end - # The compiled method for the locals keys provided. - def compiled_method(locals_keys, scope_class=nil) - LOCK.synchronize do - @compiled_method[[scope_class, locals_keys]] ||= compile_template_method(locals_keys, scope_class) - end + def set_compiled_method_cache + @compiled_method = {} end def local_extraction(local_keys) - local_keys.map do |k| + assignments = local_keys.map do |k| if k.to_s =~ /\A[a-z_][a-zA-Z_0-9]*\z/ "#{k} = locals[#{k.inspect}]" else raise "invalid locals key: #{k.inspect} (keys must be variable names)" end - end.join("\n") + end + + s = "locals = locals[:locals]" + if assignments.delete(s) + # If there is a locals key itself named `locals`, delete it from the ordered keys so we can + # assign it last. This is important because the assignment of all other locals depends on the + # `locals` local variable still matching the `locals` method argument given to the method + # created in `#compile_template_method`. + assignments << s + end + + assignments.join("\n") end def compile_template_method(local_keys, scope_class=nil) source, offset = precompiled(local_keys) local_code = local_extraction(local_keys) method_name = "__tilt_#{Thread.current.object_id.abs}" method_source = String.new + method_source.force_encoding(source.encoding) - if method_source.respond_to?(:force_encoding) - method_source.force_encoding(source.encoding) - end - if freeze_string_literals? method_source << "# frozen-string-literal: true\n" end # Don't indent method source, to avoid indentation warnings when using compiled paths @@ -291,15 +336,15 @@ offset += method_source.count("\n") method_source << source method_source << "\nend;end;" - bind_compiled_method(method_source, offset, scope_class, local_keys) + bind_compiled_method(method_source, offset, scope_class) unbind_compiled_method(method_name) end - def bind_compiled_method(method_source, offset, scope_class, local_keys) + def bind_compiled_method(method_source, offset, scope_class) path = compiled_path if path && scope_class.name path = path.dup if defined?(@compiled_path_counter) @@ -338,15 +383,20 @@ method = TOPOBJECT.instance_method(method_name) TOPOBJECT.class_eval { remove_method(method_name) } method end - def extract_encoding(script) - extract_magic_comment(script) || script.encoding + def extract_encoding(script, &block) + extract_magic_comment(script, &block) || script.encoding end def extract_magic_comment(script) + if script.frozen? + script = script.dup + yield script + end + binary(script) do script[/\A[ \t]*\#.*coding\s*[=:]\s*([[:alnum:]\-_]+).*$/n, 1] end end @@ -358,8 +408,48 @@ original_encoding = string.encoding string.force_encoding(Encoding::BINARY) yield ensure string.force_encoding(original_encoding) + end + end + + class StaticTemplate < Template + def self.subclass(mime_type: 'text/html', &block) + Class.new(self) do + self.default_mime_type = mime_type + + private + + define_method(:_prepare_output, &block) + end + end + + # Static templates always return the prepared output. + def render(scope=nil, locals=nil) + @output + end + + # Raise NotImplementedError, since static templates + # do not support compiled methods. + def compiled_method(locals_keys, scope_class=nil) + raise NotImplementedError + end + + # Static templates never allow script. + def allows_script? + false + end + + protected + + def prepare + @output = _prepare_output + end + + private + + # Do nothing, since compiled method cache is not used. + def set_compiled_method_cache end end end