module MasterView class RenderLevel #contains render modes, each gen level attr_accessor :render_modes def initialize(render_modes = []) @render_modes = render_modes end def push(render_mode) @render_modes.push render_mode end end class RenderMode #contains tags and output, each output style attr_accessor :output, :tags, :mode_type def initialize(output = nil, mode_type = :normal) @output = output @tags = [] @mode_type = mode_type end def tag @tags.last end def directives @tags.last.directives end def render_directives(method_name, context=tag.create_context) dcs = DirectiveCallStack.new tag_name = tag.tag_name.downcase depth = tags.size-1 @tags.each do |tag| case depth when 0 dcs << tag.directives.determine_dcs(method_name) when 1 dcs << tag.directives.determine_dcs(determine_method(:child, tag_name, method_name)) dcs << tag.directives.determine_dcs(determine_method(:child, :any, method_name)) dcs << tag.directives.determine_dcs(determine_method(:descendant, tag_name, method_name)) dcs << tag.directives.determine_dcs(determine_method(:descendant, :any, method_name)) else dcs << tag.directives.determine_dcs(determine_method(:descendant, tag_name, method_name)) dcs << tag.directives.determine_dcs(determine_method(:descendant, :any, method_name)) end depth -= 1 end dcs.context = context dcs.render end def determine_method(modifier, tag_name, base) type = "#{modifier.to_s}_#{tag_name.to_s}_#{base.to_s}" end end class DirectiveCallStack attr_reader :directives_to_call attr_accessor :context def initialize @directives_to_call = [] end def <<(directive_calls) if directive_calls.is_a? DirectiveCallStack @directives_to_call << directive_calls.directives_to_call else @directives_to_call << directive_calls end @directives_to_call.flatten! self end def render return [] if @directives_to_call.empty? directive_proc = @directives_to_call.shift directive_proc.call(self) end end class DirectiveSet attr_accessor :directives def initialize @directives = [] end def <<(directive) @directives << directive @directives.flatten! self end def determine_dcs(method_name) method_name_sym = method_name.to_sym dcs = DirectiveCallStack.new @directives.each do |directive| if directive.respond_to? method_name_sym dcs << create_call_proc(directive, method_name_sym) end end dcs end def create_call_proc(directive, method_name_sym) lambda do |dcs| directive.save_directive_call_stack(dcs) if directive.respond_to? :save_directive_call_stack directive.send(method_name_sym, dcs) end end end class SimpleRenderHandler def description 'SimpleRenderHandler is the default renderer for nodes, it should be invoked as the last directive and will output node normally' end def stag(dcs) context = dcs.context ret = [] ret << "<#{context[:tag].tag_name}" context[:tag].attributes.sort.each do |name, value| ret << " #{name}=\"#{value}\"" end ret << '>' #must output as separate string so simplify_empty_elements can find it end def characters(dcs) context = dcs.context [] << context[:content_part] end def comment(dcs) context = dcs.context [] << '' end def cdata(dcs) context = dcs.context [] << '' end def etag(dcs) context = dcs.context [] << '" #must output self, :mode_type => @mode_type }.merge!(values) end def data [] << @stag << @content << @etag end end class InvalidPathException < Exception def initialize(msg) super(msg) end end # Serializer which can serialize output to file system. # It will create any directories that are necessary before writing the file. # It will overwrite any existing file that existed. class FileSerializer def serialize(render_mode, tag) Log.debug { "outputting mode=#{render_mode.mode_type} to file=#{render_mode.output}".indent(2*(Renderer.last_renderer.render_levels.size - 1)) } dir_name = File.dirname render_mode.output FileUtils.makedirs(dir_name) unless File.exist?(dir_name) #ensure path exists data_to_write = tag.data.join if File.exist? render_mode.output existing_file_contents = File.readlines(render_mode.output).join if data_to_write == existing_file_contents Log.debug { "file identical, skipping output of #{render_mode.output}" } return false end end File.open(render_mode.output, 'w') do |io| io << data_to_write end true end end # Serializer which simply serializes output to the console class ConsoleSerializer def serialize(render_mode, tag) puts "outputing mode=#{render_mode.mode_type} to file=#{render_mode.output}" puts tag.data.join puts '' true end end # Serializer which simply outputs each fragment to a hash with the key representing # the path and the value the string contents. # You may specify this serializer as an option to the parser (:serializer => HashSerializer.new(output_hash)). # It takes an empty hash as the single constructor parameter to which the contents will be output. class HashSerializer def initialize( output_hash ) @output_hash = output_hash end def serialize(render_mode, tag) Log.debug { "adding to hash - outputing mode=#{render_mode.mode_type} to file=#{render_mode.output}" } @output_hash[ render_mode.output ] = tag.data.join true end end class Renderer include DirectiveHelpers attr_reader :restrict_output_to_directory, :directive_load_paths, :mv_ns attr_accessor :render_levels, :directive_classes, :default_render_handler, :serializer, :template_path def self.last_renderer; @@last_renderer; end def initialize( options = {} ) @@last_renderer = self; #save last renderer for convenient access @default_render_handler = SimpleRenderHandler.new @render_levels = [ RenderLevel.new( [RenderMode.new] ) ] serializer = options[:serializer] || DefaultSerializer self.serializer = serializer.is_a?(Class) ? serializer.new : serializer #one can pass in Serializer class or an instance self.restrict_output_to_directory = options[:output_dir] || nil self.mv_ns = options[:namespace] || NamespacePrefix self.directive_load_paths = ( DefaultDirectiveLoadPaths << options[:additional_directive_paths] ).flatten self.template_path = options[:template_path] || '' end def mv_ns=(namespace_prefix) @mv_ns = namespace_prefix Log.debug { 'namespace_prefix set to '+namespace_prefix } end def restrict_output_to_directory=(dir) @restrict_output_to_directory = (!dir.nil?) ? File.expand_path(dir) : nil Log.debug { 'restrict_output_to_directory set to '+@restrict_output_to_directory.to_s } end # Sets directive_load_paths, re-requiring all the new load paths, however any directives that were # already required (and loaded) will still be in memory because these are not reset. def directive_load_paths=( directive_paths ) @directive_classes = {} @auto_directives = [] directive_paths.each do |directive_path| next if directive_path.nil? raise InvalidPathException.new('directive_path does not exist, path='+directive_path) unless File.exist? directive_path Dir.open( directive_path ).each { |fn| require "#{directive_path}/#{fn}" if fn =~ /[.]rb$/ } end Log.debug { 'directive plugins loaded:' + (DirectiveBase.loaded_classes.collect do |c| c.name.split(':').last #strip off Module prefixes for brevity end).inspect } DirectiveBase.loaded_classes.each do |lc| lc.on_load if lc.respond_to?(:on_load) full_attr_name = (lc.respond_to? :full_attr_name) ? lc.full_attr_name(@mv_ns) : build_full_attribute_name(@mv_ns, lc) @directive_classes[full_attr_name] = lc lcinstance = lc.new(nil) @auto_directives << lc if lcinstance.respond_to?(:global_directive?) && lcinstance.global_directive? end Log.debug { 'auto_directives='+@auto_directives.inspect } end # this method is invoked to build the full attribute name (the attribute which will be watched for in # html attibutes. It concatenates the namespace prefix to the class name after first removing any module # prefixes and then downcasing the first letter def build_full_attribute_name(mv_ns, directive_class) mv_ns+directive_class.name.split(':').last.downcase_first_letter end def modes @render_levels.last.render_modes end def push_level(render_level) render_level.render_modes.each do |mode| enforce_sandbox!(mode.output) end @render_levels.push render_level end def enforce_sandbox!(path) unless @restrict_output_to_directory.nil? expanded_path = File.expand_path(path, @restrict_output_to_directory) unless expanded_path.starts_with? @restrict_output_to_directory raise InvalidPathException.new( "invalid path=#{path} resticted to path=#{@restrict_output_to_directory}") end path.replace expanded_path end end def push_tag(tag_name, attributes) modes.each do |mode| attributes_copy = attributes.clone #these get changed in select_active_directives directives = select_active_directives(tag_name, attributes_copy, mode) parent = (mode.tags.empty?) ? nil : mode.tag mode.tags.push Tag.new(directives, tag_name, attributes_copy, mode.mode_type, parent) mode.tag.stag << mode.render_directives(:stag) end end def append_content(type, content) modes.each do |mode| if mode.tag mode.tag.content << mode.render_directives( type, mode.tag.create_context(:content_part => content) ) end end end #does not call any directives, direct output def append_raw(raw_output) modes.each do |mode| if mode.tag mode.tag.content << raw_output end end end def pop_level @render_levels.pop end def pop_tag need_to_pop_level = false modes.each do |mode| mode.tag.etag << mode.render_directives(:etag) content = [] content << mode.tag.stag << mode.tag.content << mode.tag.etag content = simplify_empty_elements content last_tag = mode.tags.pop if mode.tags.empty? unless mode.output.nil? @serializer.serialize(mode, last_tag) need_to_pop_level = true end else #add it to the parent mode.tag.content << content end end pop_level if need_to_pop_level end def simplify_empty_elements(content) #relies on the fact that > and ' && last == '' ret.pop #remove '' else ret << item end next_to_last = last last = item end ret end def capitalize_first_letter(string) string[0,1].upcase + string[1..-1] end def select_active_directives(tag_name, attributes, mode) selected = DirectiveSet.new directives_needed = [] @auto_directives.each do |directive_class| directives_needed << directive_class.new(nil) end @directive_classes.each do |key,directive_class| directives_needed << directive_class.new(attributes.delete(key)) if attributes[key] end sorted_directives = directives_needed.sort do |x,y| xval = (x.respond_to?(:priority)) ? x.priority : DirectivePriorities::Medium yval = (y.respond_to?(:priority)) ? y.priority : DirectivePriorities::Medium xval <=> yval end sorted_directives << @default_render_handler #insure this is last selected << sorted_directives end end class MasterViewListener include REXML::SAX2Listener include DirectiveHelpers def initialize( options = {} ) @renderer = Renderer.new(options) end def xmldecl(version, encoding, standalone) #todo end def start_document #todo end def doctype(name, pub, sys, long_name, uri) #todo end def start_element(uri, localname, qname, attributes) unescape_attributes!(attributes) push_levels(attributes) @renderer.push_tag(qname, attributes) end def characters(text) @renderer.append_content(:characters, text) end def comment(comment) @renderer.append_content(:comment, comment) end def cdata(content) @renderer.append_content(:cdata, content) end def end_element(uri, localname, qname) @renderer.pop_tag end def end_document #todo end def unescape_attributes!(attributes) attributes.each do |name, value| value.replace CGI::unescapeHTML(value) value.gsub!(''', '\'') end end def generate_replace(value) @renderer.append_raw ERB_EVAL+value+ERB_END end # handle a mv:gen_render attribute, which calls generate and outputs a token # it takes an optional :dir => 'foo/bar' which is prepended to partial path, # otherwise it just uses what is in partial. # This creates a generate attribute value which will be used later. # Parameters # value = attribute value for gen_render # attributes = all remaining attributes hash def generate_render(value, attributes) prepend_dir = find_string_val_in_string_hash(value, :dir) #only used for masterview partial = find_string_val_in_string_hash(value, :partial) return if partial.nil? dir = File.dirname(partial) base = File.basename(partial) filename = '_'+base+'.rhtml' path = ( (dir != '.') ? File.join(dir,filename) : filename ) path = File.join(prepend_dir, path) if prepend_dir generate_attribute = attributes[@renderer.mv_ns+'generate'] || '' generate_attribute = path + (generate_attribute.blank? ? '' : ', '+generate_attribute) attributes[@renderer.mv_ns+'generate'] = generate_attribute @renderer.append_raw( ERB_EVAL+'render( '+value+' )'+ERB_END ) end def push_levels(attributes) gen_render = attributes.delete(@renderer.mv_ns+'gen_render') #get and delete from map generate_render( gen_render, attributes ) unless gen_render.nil? gen_replace = attributes.delete(@renderer.mv_ns+'gen_replace') #get and delete from map generate_replace( gen_replace ) unless gen_replace.nil? gen = attributes.delete(@renderer.mv_ns+'generate') #get and delete from map unless gen.nil? attributes[@renderer.mv_ns+'insert_generated_comment'] = @renderer.template_path unless OmitGeneratedComments #add the comment directive, so it will be written to each gen'd file render_level = nil gen_values = parse_eval_into_hash(gen, :normal) #Log.debug { 'generate_hash='+gen_values.inspect } gen_values.each do |key,value| mode_type = key.to_sym arr_values = (value.is_a?(Enumerable)) ? value : [value] #if not enumerable add it to array value.each do |path| path.strip! Log.debug { ('pushing mode='+mode_type.to_s+' path='+path).indent(2*@renderer.render_levels.size) } render_level ||= RenderLevel.new render_level.push RenderMode.new(path, mode_type) end end @renderer.push_level(render_level) unless render_level.nil? end end end class Parser # parse a MasterView template by first reading from file and render output. # template_file_path param is file path to template # options are the optional parameters which control the output (:output_dir, :namespace) def self.parse_file( template_file_path, output_dir, options = DefaultParserOptions.clone) Log.debug { "Parsing file=#{File.expand_path(template_file_path)} output_dir=#{File.expand_path(output_dir)}" } options[:template_path]=File.expand_path(template_file_path) options[:output_dir] = output_dir template = File.new( template_file_path ) template = template.readlines.join if options[:tidy] || options[:escape_erb] self.parse( template, options ) end # parse a MasterView template and render output. # template param is actual template source passed in as string or array. # options are the optional parameters which control the output (:output_dir, :namespace, :serializer) def self.parse( template, options = DefaultParserOptions.clone) begin if options[:tidy] template = self.tidy(template) elsif options[:escape_erb] template = self.escape_erb(template) end parser = REXML::Parsers::SAX2Parser.new( template ) parser.listen( MasterViewListener.new(options) ) parser.parse rescue Exception => e if RescueExceptions Log.error { "Failure to parse template. Exception="+e } Log.debug { e.backtrace.join("\n") } else raise end end end def self.tidy(html) Tidy.path = TidyPath unless Tidy.path xml = Tidy.open do |tidy| tidy.options.output_xml = true tidy.options.indent = true tidy.options.wrap = 0 xml = tidy.clean(html) end xml = self.escape_erb(xml) Log.debug { 'tidy corrected xml='+xml } xml end def self.escape_erb(html) html = html.gsub(/<%/, InlineErbStart) html.gsub!(/%>/, InlineErbEnd) html end end end