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 = { '&'=>'&', '<'=>'<', '>'=>'>', '"'=>'"', "'"=>''' }
+ ## escapes '&', '<', '>', and '"'
def escape_xml(s)
- #return s.gsub(/[&<>"]/) { XML_ESCAPE_TABLE[$&] }
- return s.gsub(/[&<>"]/) { |s| XML_ESCAPE_TABLE[s] }
- ##
- #s = s.gsub(/&/, '&')
- #s.gsub!(/</, '<')
- #s.gsub!(/>/, '>')
- #s.gsub!(/"/, '"')
- #return s
- ##
- #return s.gsub(/&/, '&').gsub(/</, '<').gsub(/>/, '>').gsub(/"/, '"')
+ #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 " "
def text2html(text)
- return nl2br(escape_xml(text.to_s).gsub(/ /, ' '))
+ return nl2br(safe_escape(text.to_s).gsub(/ /, ' '))
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