lib/tenjin.rb in tenjin-0.6.2 vs lib/tenjin.rb in tenjin-0.7.0

- old
+ new

@@ -1,7 +1,7 @@ ## -## copyright(c) 2007-2008 kuwata-lab.com all rights reserved. +## copyright(c) 2007-2011 kuwata-lab.com all rights reserved ## ## Permission is hereby granted, free of charge, to any person obtaining ## a copy of this software and associated documentation files (the ## "Software"), to deal in the Software without restriction, including ## without limitation the rights to use, copy, modify, merge, publish, @@ -20,108 +20,233 @@ ## OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION ## WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## ## -## Tenjin module +## Tenjin -- a very fast and full-featured template engine ## -## $Rev: 65 $ -## $Release: 0.6.2 $ +## $Release: 0.7.0 $ +## copyright(c) 2007-2011 kuwata-lab.com all rights reserved +## $License: MIT License $ ## module Tenjin - RELEASE = ('$Release: 0.6.2 $' =~ /[\d.]+/) && $& + RELEASE = ('$Release: 0.7.0 $' =~ /[\d.]+/) && $& ## - ## helper module for Context class + ## logger ## + @logger = nil + + def self.logger + return @logger + end + + def self.logger=(logger) + @logger = logger + end + + + ## + ## helper module for Context class. + ## depends on SafeHelper. + ## module HtmlHelper module_function XML_ESCAPE_TABLE = { '&'=>'&amp;', '<'=>'&lt;', '>'=>'&gt;', '"'=>'&quot;', "'"=>'&#039;' } + ## escapes '&', '<', '>', and '"' def escape_xml(s) - #return s.gsub(/[&<>"]/) { XML_ESCAPE_TABLE[$&] } - return s.gsub(/[&<>"]/) { |s| XML_ESCAPE_TABLE[s] } - ## - #s = s.gsub(/&/, '&amp;') - #s.gsub!(/</, '&lt;') - #s.gsub!(/>/, '&gt;') - #s.gsub!(/"/, '&quot;') - #return s - ## - #return s.gsub(/&/, '&amp;').gsub(/</, '&lt;').gsub(/>/, '&gt;').gsub(/"/, '&quot;') + #s.gsub(/[&<>"]/) { XML_ESCAPE_TABLE[$&] } + s.gsub(/[&<>"]/) {|s| XML_ESCAPE_TABLE[s] } end - alias escape escape_xml + ## escapes '&', '<', '>', '"', and '\'' + def escape_html(s) + #s.gsub(/[&<>"']/) { XML_ESCAPE_TABLE[$&] } + s.gsub(/[&<>"']/) {|s| XML_ESCAPE_TABLE[s] } + end + if RUBY_VERSION >= "1.9" + def escape_xml(s) + s.gsub(/[&<>"]/, XML_ESCAPE_TABLE) + end + def escape_html(s) + s.gsub(/[&<>"']/, XML_ESCAPE_TABLE) + end + end + + alias escape escape_html + alias h escape_html + + end + + + ## + ## helper module for html tags. + ## depends on HtmlHelper and SafeHelper. + ## + module HtmlTagHelper + + module_function + ## (experimental) return ' name="value"' if expr is not false nor nil. ## if value is nil or false then expr is used as value. def tagattr(name, expr, value=nil, escape=true) if !expr return '' elsif escape - return " #{name}=\"#{escape_xml((value || expr).to_s)}\"" + return safe_str(" #{name}=\"#{safe_escape((value || expr).to_s)}\"") else - return " #{name}=\"#{value || expr}\"" + return safe_str(" #{name}=\"#{value || expr}\"") end end ## return ' checked="checked"' if expr is not false or nil def checked(expr) - return expr ? ' checked="checked"' : '' + return expr ? safe_str(' checked="checked"') : '' end ## return ' selected="selected"' if expr is not false or nil def selected(expr) - return expr ? ' selected="selected"' : '' + return expr ? safe_str(' selected="selected"') : '' end ## return ' disabled="disabled"' if expr is not false or nil def disabled(expr) - return expr ? ' disabled="disabled"' : '' + return expr ? safe_str(' disabled="disabled"') : '' end + ## (experimental) return ' name="name" value="value"' string. + ## if separator specified then id attribute is also added. + def nv(name, value, separator=nil) + nattr = safe_escape(name.to_s) + vattr = safe_escape(value.to_s) + s = separator \ + ? " name=\"#{nattr}\" value=\"#{vattr}\" id=\"#{nattr}#{separator}#{vattr}\"" \ + : " name=\"#{nattr}\" value=\"#{vattr}\"" + return safe_str(s) + end + + ## return '<a href="" onclick="js_code;return">label</a>'. + def js_link(label, js_code, tags=nil) + return safe_str(%Q`<a href="javascript:undefined" onclick="#{safe_escape(js_code.to_s)};return false"#{_hash2attr(tags)}>#{safe_escape(label.to_s)}</a>`) + end + + def _hash2attr(hash) # :nodoc: + attr = "" + return attr unless hash + hash.each_pair do |k, v| + attr << " #{safe_escape(k.to_s)}=\"#{safe_escape(v.to_s)}\"" + end + return attr + end + private :_hash2attr + ## convert "\n" into "<br />\n" def nl2br(text) - return text.to_s.gsub(/\n/, "<br />\n") + return safe_str(text.to_s.gsub(/\n/, "<br />\n")) end ## convert "\n" and " " into "<br />\n" and " &nbsp;" def text2html(text) - return nl2br(escape_xml(text.to_s).gsub(/ /, ' &nbsp;')) + return nl2br(safe_escape(text.to_s).gsub(/ /, ' &nbsp;')) end + ## cycle values everytime when #to_s() is called + ## ex: + ## cycle = Cycle.new('odd', 'even') + ## "#{cycle}" #=> 'odd' + ## "#{cycle}" #=> 'even' + ## "#{cycle}" #=> 'odd' + class Cycle + attr_reader :values + def initialize(*values) + @values = values.freeze + @length = values.length + @index = -1 + end + attr_reader :index + def next + return @values[(@index += 1) % @length] + end + alias :to_s :next + def count + @index + 1 + end + end + + def new_cycle(*values) + return Cycle.new(*values) + end + end ## + ## + ## + class SafeString < String + def to_s + self + end + end + + + module SafeHelper + #-- + #escape() should be defined + #++ + + module_function + + ## return SafeString object + def safe_str(s) + SafeString.new(s.to_s) + end + + ## return true if s is SafeString object + def safe_str?(s) + s.is_a?(SafeString) + end + + ## escape val only if val is not SafeString object, and return SafeString object + def safe_escape(val) + safe_str?(val) ? val : safe_str(escape(val)) + end + + end + + + ## ## helper module for BaseContext class ## module ContextHelper - attr_accessor :_buf, :_engine, :_layout + attr_accessor :_buf, :_engine, :_layout, :_template ## escape value. this method should be overrided in subclass. def escape(val) return val end ## include template. 'template_name' can be filename or short name. def import(template_name, _append_to_buf=true) _buf = self._buf output = self._engine.render(template_name, context=self, layout=false) + self._buf = _buf _buf << output if _append_to_buf return output end ## add value into _buf. this is equivarent to '#{value}'. def echo(value) - self._buf << value + self._buf << value.to_s end ## ## start capturing. ## returns captured string if block given, else return nil. @@ -221,10 +346,50 @@ s.gsub!(/<`\#(.*?)\#`>/m, '#{\1}') s.gsub!(/<`\$(.*?)\$`>/m, '${\1}') return s end + ## + ## cache fragment data + ## + ## ex. + ## kv_store = Tenjin::FileBaseStore.new("/var/tmp/myapp/dacache") + ## Tenjin::Engine.data_cache = kv_store + ## engine = Tenjin::Engine.new + ## # or engine = Tenjin::Engine.new(:data_cache=>kv_store) + ## entries = proc { Entry.find(:all) } + ## html = engine.render("index.rbhtml", {:entries => entries}) + ## + ## index.rbhtml: + ## <html> + ## <body> + ## <?rb cache_with("entries/index", 5*60) do ?> + ## <?rb entries = @entries.call ?> + ## <ul> + ## <?rb for entry in entries ?> + ## <li>${entry.title}</li> + ## <?rb end ?> + ## </ul> + ## <?rb end ?> + ## </body> + ## </html> + ## + def cache_with(cache_key, lifetime=nil) + kv_store = self._engine.data_cache or + raise ArgumentError.new("data_cache object is not set for engine object.") + data = kv_store.get(cache_key, self._template.timestamp) + if data + echo data + else + pos = self._buf.length + yield + data = self._buf[pos..-1] + kv_store.set(cache_key, data, lifetime) + end + nil + end + end ## ## base class for Context class @@ -278,10 +443,12 @@ ## ## context class for Template ## class Context < BaseContext include HtmlHelper + include HtmlTagHelper + include SafeHelper end ## ## template class @@ -323,10 +490,15 @@ ## print output ## class Template ESCAPE_FUNCTION = 'escape' # or 'Eruby::Helper.escape' + TRACE = false + def self.TRACE=(boolean) + remove_const :TRACE + const_set :TRACE, boolean + end ## ## initializer of Template class. ## ## options: @@ -340,17 +512,24 @@ filename = nil end @filename = filename @escapefunc = options[:escapefunc] || ESCAPE_FUNCTION @preamble = options[:preamble] == true ? "_buf = #{init_buf_expr()}; " : options[:preamble] - @postamble = options[:postamble] == true ? "_buf.to_s" : options[:postamble] + @postamble = options[:postamble] == true ? finish_buf_expr() : options[:postamble] + @input = options[:input] + @trace = options[:trace] || TRACE @args = nil # or array of argument names - convert_file(filename) if filename + if @input + convert(@input, filename) + elsif filename + convert_file(filename) + end end attr_accessor :filename, :escapefunc, :initbuf, :newline attr_accessor :timestamp, :args attr_accessor :script #,:bytecode + attr_accessor :_last_checked_at ## convert file into ruby code def convert_file(filename) return convert(File.read(filename), filename) end @@ -387,13 +566,18 @@ @script << @newline unless @script[-1] == ?\n @script << @postamble << @newline if @postamble end def self.compile_stmt_pattern(pi) - return /<\?#{pi}( |\t|\r?\n)(.*?) ?\?>([ \t]*\r?\n)?/m + return /(^[ \t]*)?<\?#{pi}(\s)(.*?) ?\?>([ \t]*\r?\n)?/m end + def capture_stmt(matched) + #: return lspace, mspace, code, and rspace + return matched.captures() + end + STMT_PATTERN = self.compile_stmt_pattern('rb') def stmt_pattern STMT_PATTERN end @@ -402,60 +586,28 @@ def parse_stmts(input) return unless input is_bol = true prev_rspace = nil pos = 0 - input.scan(stmt_pattern()) do |mspace, code, rspace| + input.scan(stmt_pattern()) do m = Regexp.last_match + lspace, mspace, code, rspace = capture_stmt(m) text = input[pos, m.begin(0) - pos] pos = m.end(0) - ## detect spaces at beginning of line - lspace = nil - if rspace.nil? - # nothing - elsif text.empty? - lspace = "" if is_bol - elsif text[-1] == ?\n - lspace = "" - else - rindex = text.rindex(?\n) - if rindex - s = text[rindex+1..-1] - if s =~ /\A[ \t]*\z/ - lspace = s - text = text[0..rindex] - #text[rindex+1..-1] = '' - end - else - if is_bol && text =~ /\A[ \t]*\z/ - lspace = text - text = nil - #lspace = text.dup - #text[0..-1] = '' - end - end - end - is_bol = rspace ? true : false ## text.insert(0, prev_rspace) if prev_rspace - parse_exprs(text) - code.insert(0, mspace) if mspace != ' ' - if lspace - assert if rspace.nil? - code.insert(0, lspace) - code << rspace - #add_stmt(code) - prev_rspace = nil + prev_rspace = nil + code = "#{mspace}#{code}" unless mspace == ' ' + if lspace && rspace + code = "#{lspace}#{code}#{rspace}" else - code << ';' unless code[-1] == ?\n - #add_stmt(code) - prev_rspace = rspace + code << ";" unless code[-1] == ?\n + text << lspace if lspace && !lspace.empty? + prev_rspace = rspace if rspace && !rspace.empty? end - if code - code = statement_hook(code) - add_stmt(code) - end + parse_exprs(text) + add_stmt(statement_hook(code)) if code && !code.empty? end #rest = $' || input rest = pos > 0 ? input[pos..-1] : input rest.insert(0, prev_rspace) if prev_rspace parse_exprs(rest) if rest && !rest.empty? @@ -606,25 +758,37 @@ private ## create proc object def _render() # :nodoc: - return eval("proc { |_context| self._buf = _buf = #{init_buf_expr()}; #{@script}; _buf.to_s }".untaint, nil, @filename || '(tenjin)') + return eval("proc { |_context| self._buf = _buf = #{init_buf_expr()}; #{@script}; #{finish_buf_expr()} }".untaint, nil, @filename || '(tenjin)') end public def init_buf_expr() # :nodoc: return "''" end + def finish_buf_expr() # :nodoc: + return "_buf.to_s" + end + ## evaluate converted ruby code and return it. ## argument '_context' should be a Hash object or Context object. def render(_context=Context.new) _context = Context.new(_context) if _context.is_a?(Hash) @proc ||= _render() - return _context.instance_eval(&@proc) + if @trace + s = "" + s << "<!-- ***** begin: #{@filename} ***** -->\n" + s << _context.instance_eval(&@proc) + s << "<!-- ***** end: #{@filename} ***** -->\n" + return s + else + return _context.instance_eval(&@proc) + end end end @@ -689,10 +853,15 @@ ## _buf.push('</ul> ## '); ## class ArrayBufferTemplate < Template + #def initialize(filename=nil, options={}) + # options[:postamble] = options[:postamble] == true ? '_buf.join' : options[:postamble] + # super(filename, options) + #end + protected def expr_pattern return /([\#$])\{(.*?)\}/ end @@ -722,10 +891,16 @@ def quote_expr(expr, flag_escape) return flag_escape ? "#{@escapefunc}((#{expr}).to_s)" : "(#{expr}).to_s" # or "(#{expr})" end + private + + def _render() # :nodoc: + return eval("proc { |_context| self._buf = _buf = #{init_buf_expr()}; #{@script}; _buf.join }".untaint, nil, @filename || '(tenjin)') + end + #-- #def get_macro_handler(name) # if name == "start_capture" # return proc { |arg| # " _buf_bkup = _buf; _buf = []; _capture_varname = #{arg};" @@ -744,10 +919,14 @@ def init_buf_expr() # :nodoc: return "[]" end + def finish_buf_expr() # :nodoc: + return "_buf.join" + end + end ## ## template class to use eRuby template file (*.rhtml) instead of @@ -770,10 +949,353 @@ end ## + ## + ## + class SafeTemplate < Template + + ESCAPE_FUNCTION = 'safe_escape' + + def initialize(filename=nil, options={}) + options, filename = filename, nil if filename.is_a?(Hash) + options[:escapefunc] ||= 'safe_escape' + super(filename, options) + end + + ## escape '#' in addition '\\' and '`' + def escape_str(str) + str.gsub!(/[`\#\\]/, '\\\\\&') + str.gsub!(/\r\n/, "\\r\r\n") if @newline == "\r\n" + return str + end + + end + + + ## + ## abstract class for template cache + ## + class TemplateCache + + def save(cachepath, template) + raise NotImplementedError.new("#{self.class.name}#save(): not implemented yet.") + end + + def load(cachepath, timestamp=nil) + raise NotImplementedError.new("#{self.class.name}#load(): not implemented yet.") + end + + end + + + ## + ## dummy template cache + ## + class NullTemplateCache < TemplateCache + + def save(cachepath, template) + ## do nothing. + end + + def load(cachepath, timestamp=nil) + ## do nothing. + end + + end + + + ## + ## file base template cache which saves template script into file + ## + class FileBaseTemplateCache < TemplateCache + + def save(cachepath, template) + #: save template script and args into cache file. + t = template + tmppath = "#{cachepath}#{rand().to_s[1,8]}" + s = t.args ? "\#@ARGS #{t.args.join(',')}\n" : '' + File.open(tmppath, 'wb') {|f| f.write(s); f.write(t.script) } + #: set cache file's mtime to template timestamp. + File.utime(t.timestamp, t.timestamp, tmppath) if t.timestamp + File.rename(tmppath, cachepath) + Tenjin.logger.debug("[tenjin.rb:#{__LINE__}] cache saved (cachefile=#{cachepath.inspect}).") if Tenjin.logger + end + + def load(cachepath, timestamp=nil) + # 'timestamp' argument has mtime of template file + #: load template data from cache file. + begin + #: if template timestamp is specified and different from that of cache file, return nil + mtime = File.mtime(cachepath) + if timestamp && mtime != timestamp + #File.unlink(cachepath) + Tenjin.logger.debug("[tenjin.rb:#{__LINE__}] cache expired (cachefile=#{cachepath.inspect}).") if Tenjin.logger + return nil + end + script = File.open(cachepath, 'rb') {|f| f.read } + rescue Errno::ENOENT => ex + #: if cache file is not found, return nil. + Tenjin.logger.debug("[tenjin.rb:#{__LINE__}] cache not found (cachefile=#{cachepath.inspect}).") if Tenjin.logger + return nil + end + #: get template args data from cached data. + args = script.sub!(/\A\#@ARGS (.*)\r?\n/, '') ? $1.split(/,/) : [] + #: return script, template args, and mtime of cache file. + Tenjin.logger.debug("[tenjin.rb:#{__LINE__}] cache found (cachefile=#{cachepath.inspect}).") if Tenjin.logger + return [script, args, mtime] + end + + end + + + ## + ## abstract class for data cache (= html fragment cache) + ## + class KeyValueStore + + def get(key, *options) + raise NotImplementedError.new("#{self.class.name}#get(): not implemented yet.") + end + + def set(key, value, *options) + raise NotImplementedError.new("#{self.class.name}#set(): not implemented yet.") + end + + def del(key, *options) + raise NotImplementedError.new("#{self.class.name}#del(): not implemented yet.") + end + + def has?(key, *options) + raise NotImplementedError.new("#{self.class.name}#has(): not implemented yet.") + end + + def [](key) + return get(key) + end + + def []=(key, value) + return set(key, value) + end + + end + + + ## + ## memory base data store + ## + class MemoryBaseStore < KeyValueStore + + def initialize(lifetime=604800) + @values = {} + @lifetime = lifetime + end + attr_accessor :values, :lifetime + + def set(key, value, lifetime=nil) + #: store key and value with current and expired timestamp + now = Time.now + @values[key] = [value, now, now + (lifetime || @lifetime)] + end + + def get(key, original_timestamp=nil) + #: if cache data is not found, return nil + arr = @values[key] + return nil if arr.nil? + #: if cache data is older than original data, remove it and return nil + value, created_at, timestamp = arr + if original_timestamp && created_at < original_timestamp + del(key) + return nil + end + #: if cache data is expired then remove it and return nil + if timestamp < Time.now + del(key) + return nil + end + #: return cache data + return value + end + + def del(key) + #: remove data + #: don't raise error even if key doesn't exist + @values.delete(key) + end + + def has?(key) + #: if key exists then return true else return false + return @values.key?(key) + end + + end + + + ## + ## file base data store + ## + class FileBaseStore < KeyValueStore + + def initialize(root, lifetime=604800) # = 60*60*24*7 + self.root = root + self.lifetime = lifetime + end + attr_accessor :root, :lifetime + + def root=(path) + unless File.directory?(path) + raise ArgumentError.new("#{path}: not found.") unless File.exist?(path) + raise ArgumentError.new("#{path}: not a directory.") + end + path = path.chop if path[-1] == ?/ + @root = path + end + + def filepath(key) + #return File.join(@root, key.gsub(/[^-.\w\/]/, '_')) + return "#{@root}/#{key.gsub(/[^-.\w\/]/, '_')}" + end + + def set(key, value, lifetime=nil) + #: create directory for cache + fpath = filepath(key) + dir = File.dirname(fpath) + unless File.exist?(dir) + require 'fileutils' #unless defined?(FileUtils) + FileUtils.mkdir_p(dir) + end + #: create temporary file and rename it to cache file (in order not to flock) + tmppath = "#{fpath}#{rand().to_s[1,8]}" + _write_binary(tmppath, value) + File.rename(tmppath, fpath) + #: set mtime (which is regarded as cache expired timestamp) + timestamp = Time.now + (lifetime || @lifetime) + File.utime(timestamp, timestamp, fpath) + #: return value + return value + end + + def get(key, original_timestamp=nil) + #: if cache file is not found, return nil + fpath = filepath(key) + #return nil unless File.exist?(fpath) + stat = _ignore_not_found_error { File.stat(fpath) } + return nil if stat.nil? + #: if cache file is older than original data, remove it and return nil + if original_timestamp && stat.ctime < original_timestamp + del(key) + return nil + end + #: if cache file is expired then remove it and return nil + if stat.mtime < Time.now + del(key) + return nil + end + #: return cache file content + return _ignore_not_found_error { _read_binary(fpath) } + end + + def del(key, *options) + #: delete data file + #: if data file doesn't exist, don't raise error + fpath = filepath(key) + _ignore_not_found_error { File.unlink(fpath) } + nil + end + + def has?(key) + #: if key exists then return true else return false + return File.exist?(filepath(key)) + end + + private + + if RUBY_PLATFORM =~ /mswin(?!ce)|mingw|cygwin|bccwin/i + def _read_binary(fpath) + File.open(fpath, 'rb') {|f| f.read } + end + else + def _read_binary(fpath) + File.read(fpath) + end + end + + def _write_binary(fpath, data) + File.open(fpath, 'wb') {|f| f.write(data) } + end + + def _ignore_not_found_error(default=nil) + begin + return yield + rescue Errno::ENOENT => ex + return default + end + end + + end + + + ## + ## + ## + class TemplateNotFoundError < StandardError + end + + + ## + ## helper class for Engine to find and read files + ## + class FileFinder + + def find(filename, dirs=nil) + if dirs + #: if dirs specified then find file from it. + for dir in dirs + filepath = File.join(dir, filename) + return filepath if File.file?(filepath) + end + #found = dirs.find {|dir| File.isfile(File.join(dir, filename)) } + #return File.join(found, filename) if found + else + #: if dirs not specified then return filename if it exists. + return filename if File.file?(filename) + end + #: if file not found then return nil. + return nil + end + + def timestamp(filepath) + #: return mtime of filepath. + return File.mtime(filepath) + end + + def read(filepath) + begin + #: if file exists then return file content and mtime. + mtime = File.mtime(filepath) + input = File.open(filepath, 'rb') {|f| f.read } + mtime2 = File.mtime(filepath) + if mtime != mtime2 + mtime = mtime2 + input = File.open(filepath, 'rb') {|f| f.read } + mtime2 = File.mtime(filepath) + if mtime != mtime2 + Tenjin.logger.warn("[tenjin.rb:#{__LINE__}] #{self.class.name}#read(): timestamp is changed while reading file.") if Tenjin.logger + end + end + return input, mtime + rescue Errno::ENOENT + #: if file not found then return nil. + return nil + end + end + + end + + + ## ## engine class for templates ## ## Engine class supports the followings. ## * template caching ## * partial template @@ -821,147 +1343,251 @@ ## def initialize(options={}) @prefix = options[:prefix] || '' @postfix = options[:postfix] || '' @layout = options[:layout] - @cache = options.fetch(:cache, true) @path = options[:path] + @lang = options[:lang] + @finder = options[:finder] || FileFinder.new + @cache = _template_cache(options[:cache]) @preprocess = options.fetch(:preprocess, nil) + @data_cache = options[:data_cache] || @@data_cache @templateclass = options.fetch(:templateclass, Template) @init_opts_for_template = options - @templates = {} # filename->template + @_templates = {} # template_name => [template_obj, filepath] end + attr_accessor :prefix, :postfix, :layout, :path, :lang, :cache + attr_accessor :preprocess, :data_cache, :templateclass + def _template_cache(cache) #:nodoc: + #: if cache is nil or true then return @@template_cache + return @@template_cache if cache.nil? || cache == true + #: if cache is false then return NullTemplateCache object + return NullTemplateCache.new if cache == false + #: if cache is an instnce of TemplateClass then return it + return cache if cache.is_a?(TemplateCache) + #: if else then raises error + raise ArgumentError.new(":cache is expected true, false, or TemplateCache object") + end + private :_template_cache + + @@template_cache = FileBaseTemplateCache.new() + def self.template_cache; @@template_cache; end + def self.template_cache=(x); @@template_cache = x; end + + @@data_cache = MemoryBaseStore.new() + def self.data_cache; @@data_cache; end + def self.data_cache=(x); @@data_cache = x; end + + TIMESTAMP_INTERVAL = 1.0 + + ## register template object + def register_template(template_name, template) + #: register template object without file path. + filename = to_filename(template_name) + @_templates[filename] = [template, nil] + end + + ## returns cache file path of template file + def cachename(filepath) + #: if lang is provided then add it to cache filename. + if @lang + return "#{filepath}.#{@lang}.cache".untaint + #: return cache file name which is untainted. + else + return "#{filepath}.cache".untaint + end + end + ## convert short name into filename (ex. ':list' => 'template/list.rb.html') def to_filename(template_name) + #: if template_name is a Symbol, add prefix and postfix to it. + #: if template_name is not a Symbol, just return it. name = template_name return name.is_a?(Symbol) ? "#{@prefix}#{name}#{@postfix}" : name end - ## find template filename - def find_template_file(template_name) - filename = to_filename(template_name) - if @path - for dir in @path - filepath = "#{dir}#{File::SEPARATOR}#{filename}" - return filepath if test(?f, filepath.untaint) - end + private + + def _timestamp_changed?(template) + #: if checked within a sec, skip timestamp check and return false. + time = template._last_checked_at + now = Time.now + if time && now - time < TIMESTAMP_INTERVAL + return false + end + #: if timestamp is same as file, return false. + filepath = template.filename + if template.timestamp == @finder.timestamp(filepath) + template._last_checked_at = now + return false + #: if timestamp is changed, return true. else - return filename if test(?f, filename.dup.untaint) # dup is required for frozen string + Tenjin.logger.info("[tenjin.rb:#{__LINE__}] cache expired (template='#{template.filename}')") if Tenjin.logger + return true end - raise Errno::ENOENT.new("#{filename} (path=#{@path.inspect})") end - ## read template file and preprocess it - def read_template_file(filename, _context) - return File.read(filename) if !@preprocess - _context ||= {} - if _context.is_a?(Hash) || _context._engine.nil? - _context = hook_context(_context) - end - preprocessor = Preprocessor.new(filename) - return preprocessor.render(_context) + def _get_template_in_memory(filename) + template, filepath = @_templates[filename] + #: if template object is not in memory cache then return nil. + return nil unless template + #: if without filepath, don't check timestamp and return it. + return template unless filepath + #: if timestamp of template file is not changed, return it. + return template unless _timestamp_changed?(template) + #: if timestamp of template file is changed, clear it and return nil. + @_templates.delete(filename) + return nil end - ## register template object - def register_template(template_name, template) - #template.timestamp = Time.new unless template.timestamp - @templates[template_name] = template + def _get_template_in_cache(filepath, cachepath) + #: if template is not found in cache file, return nil. + template = @cache.load(cachepath) + return nil unless template + #: if cache returns script and args then build a template object from them. + if template.is_a?(Array) + arr = template + template = create_template(nil, nil) + template.script, template.args, template.timestamp = arr + template.filename = filepath + end + #: if timestamp of template is changed then ignore it. + return nil if _timestamp_changed?(template) + #: if timestamp is not changed then return it. + @tracer.trace("template '#{filename}' found in cache.") if @tracer + return template end - def cachename(filename) - return (filename + '.cache').untaint - end + public - ## create template object from file - def create_template(filename, _context=nil) - template = @templateclass.new(nil, @init_opts_for_template) - template.timestamp = Time.now() - cache_filename = cachename(filename) - _context = hook_context(Context.new) if _context.nil? - if !@cache - input = read_template_file(filename, _context) - template.convert(input, filename) - elsif !test(?f, cache_filename) || File.mtime(cache_filename) < File.mtime(filename) - #$stderr.puts "*** debug: load original" - input = read_template_file(filename, _context) - template.convert(input, filename) - store_cachefile(cache_filename, template) - else - #$stderr.puts "*** debug: load cache" - template.filename = filename - load_cachefile(cache_filename, template) + def get_template(template_name, _context=nil) + #: accept template name such as :index. + filename = to_filename(template_name) + #: if template object is in memory cache then return it. + template = _get_template_in_memory(filename) + return template if template + #: if template file is not found then raise TemplateNotFoundError. + filepath = @finder.find(filename, @path) or + raise TemplateNotFoundError.new("#{filename}: template not found (path=#{@path.inspect}).") + #: if template is cached in file then store it into memory and return it. + cachepath = cachename(filepath) + template = _get_template_in_cache(filepath, cachepath) + if template + @_templates[filename] = [template, filepath] + return template end + #: if template file is not found then raises TemplateNotFoundError. + ret = @finder.read(filepath) or + raise TemplateNotFoundError.new("#{filepath}: template not found.") + input, timestamp = ret + #: if preprocess is enabled then preprocess template file. + input = _preprocess(input, filepath, _context) if @preprocess + #: if template is not found in memory nor cache then create new one. + template = create_template(input, filepath) + template.filename = filepath + template.timestamp = timestamp + template._last_checked_at = Time.now + #: save template object into file cache and memory cache. + @cache.save(cachepath, template) if @cache + @_templates[filename] = [template, filepath] + #: return template object. return template end - ## store template into cache file - def store_cachefile(cache_filename, template) - s = template.script - s = "\#@ARGS #{template.args.join(',')}\n#{s}" if template.args - File.open(cache_filename, 'w') do |f| - f.flock(File::LOCK_EX) - f.write(s) - #f.lock(FIle::LOCK_UN) # File#close() unlocks automatically + private + + def _preprocess(input, filepath, _context=nil) + #: preprocess input with _context and return result. + _context ||= {} + _context = hook_context(_context) if _context.is_a?(Hash) + _buf = _context._buf + _context._buf = "" + begin + preprocessor = Preprocessor.new(nil) + preprocessor.convert(input, filepath) + return preprocessor.render(_context) + ensure + _context._buf = _buf end end - ## load template from cache file - def load_cachefile(cache_filename, template) - s = File.read(cache_filename) - if s.sub!(/\A\#\@ARGS (.*?)\r?\n/, '') - template.args = $1.split(',') - end - template.script = s + protected + + ## create template object from file + def create_template(input=nil, filepath=nil) + #: create template object and return it. + template = @templateclass.new(nil, @init_opts_for_template) + #: if input is specified then convert it into script. + template.convert(input, filepath) if input + return template end - ## get template object - def get_template(template_name, _context=nil) - template = @templates[template_name] - t = template - unless t && t.timestamp && t.filename && t.timestamp >= File.mtime(t.filename) - filename = find_template_file(template_name) - template = create_template(filename, _context) # _context is passed only for preprocessor - register_template(template_name, template) + def hook_context(context) + #: if context is nil then create new Context object + if !context + context = Context.new + #: if context is a Hash object then convert it into Context object + elsif context.is_a?(Hash) + context = Context.new(context) + #: if context is an object then use it as context object + else + # nothing end - return template + #: set _engine attribute + context._engine = self + #: set _layout attribute + context._layout = nil + #: return context object + return context end + public + ## get template object and evaluate it with context object. ## if argument 'layout' is true then default layout file (specified at ## initializer) is used as layout template, else if false then no layout ## template is used. ## if argument 'layout' is string, it is regarded as layout template name. def render(template_name, context=Context.new, layout=true) - #context = Context.new(context) if context.is_a?(Hash) + #: if context is a Hash object, convert it into Context object. context = hook_context(context) while true + # get template template = get_template(template_name, context) # context is passed only for preprocessor - _buf = context._buf + #: set template object into context (required for cache_with() helper) + _tmpl = context._template + context._template = template + # render template output = template.render(context) - context._buf = _buf + # back template + context._template = _tmpl + #: if @_layout is specified, use it as layoute template name unless context._layout.nil? layout = context._layout context._layout = nil end - layout = @layout if layout == true or layout.nil? + #: use default layout template if layout is true or nil + layout = @layout if layout == true || layout.nil? + #: if layout is false then don't use layout template break unless layout + #: set layout name as next template name template_name = layout layout = false + #: set output into @_content for layout template context.instance_variable_set('@_content', output) end return output end - def hook_context(context) - if !context - context = Context.new - elsif context.is_a?(Hash) - context = Context.new(context) - end - context._engine = self - context._layout = nil - return context + end + + + class SafeEngine < Engine + + def initialize(options={}) + options[:templateclass] = SafeTemplate + super(options) end end