pakyow-presenter/lib/presenter/view.rb in pakyow-presenter-0.7.2 vs pakyow-presenter/lib/presenter/view.rb in pakyow-presenter-0.8rc1

- old
+ new

@@ -2,145 +2,98 @@ module Presenter class View class << self attr_accessor :binders, :default_view_path, :default_is_root_view + def view_store + Pakyow.app.presenter.current_view_lookup_store + end + + def binder_for_scope(scope, bindable) + bindings = Pakyow.app.presenter.bindings(scope) + bindings.bindable = bindable + return bindings + end + def view_path(dvp, dirv=false) self.default_view_path = dvp self.default_is_root_view = dirv end + + def self_closing_tag?(tag) + %w[area base basefont br hr input img link meta].include? tag + end + + def form_field?(tag) + %w[input select textarea button].include? tag + end + + def tag_without_value?(tag) + %w[select].include? tag + end + + def at_path(view_path) + v = self.new(self.view_store.root_path(view_path), true) + v.compile(view_path) + end + + def root_at_path(view_path) + self.new(self.view_store.root_path(view_path), true) + end + end - attr_accessor :doc + attr_accessor :doc, :scoped_as, :scopes + attr_writer :bindings def dup - self.class.new(@doc.dup) + v = self.class.new(@doc.dup) + v.scoped_as = self.scoped_as + v end def initialize(arg=nil, is_root_view=false) arg = self.class.default_view_path if arg.nil? && self.class.default_view_path is_root_view = self.class.default_is_root_view if arg.nil? && self.class.default_is_root_view - - if arg.is_a?(Nokogiri::XML::Element) || arg.is_a?(Nokogiri::XML::Document) + + if arg.is_a?(Nokogiri::XML::Element) || arg.is_a?(Nokogiri::XML::Document) || arg.is_a?(Nokogiri::HTML::DocumentFragment) @doc = arg - elsif arg.is_a?(Pakyow::Presenter::Views) + elsif arg.is_a?(Pakyow::Presenter::ViewCollection) @doc = arg.first.doc.dup elsif arg.is_a?(Pakyow::Presenter::View) @doc = arg.doc.dup elsif arg.is_a?(String) - if arg[0, 1] == '/' - view_path = "#{Configuration::Presenter.view_dir}#{arg}" - else - view_path = "#{Configuration::Presenter.view_dir}/#{arg}" - end + view_path = self.class.view_store.real_path(arg) + + # run parsers + format = StringUtils.split_at_last_dot(view_path)[1].to_sym + content = parse_content(File.read(view_path), format) + if is_root_view then - @doc = Nokogiri::HTML::Document.parse(File.read(view_path)) + @doc = Nokogiri::HTML::Document.parse(content) else - @doc = Nokogiri::HTML.fragment(File.read(view_path)) + @doc = Nokogiri::HTML.fragment(content) end else raise ArgumentError, "No View for you! Come back, one year." end end - def add_content_to_container(content, container) - # TODO This .css call works but the equivalent .xpath call doesn't - # Need to investigate why since the .css call is internally turned into a .xpath call - if @doc && o = @doc.css("##{container}").first - content = content.doc unless content.class == String || content.class == Nokogiri::HTML::DocumentFragment || content.class == Nokogiri::XML::Element - o.add_child(content) - end + def compile(view_path) + return unless view_info = self.class.view_store.view_info(view_path) + self.populate_view(self, view_info[:views]) end - - def add_resource(*args) - type, resource, options = args - options ||= {} - - content = case type - when :js then '<script src="' + Pakyow::Configuration::Presenter.javascripts + '/' + resource.to_s + '.js"></script>' - when :css then '<link href="' + Pakyow::Configuration::Presenter.stylesheets + '/' + resource.to_s + '.css" rel="stylesheet" media="' + (options[:media] || 'screen, projection') + '" type="text/css">' + + def parse_content(content, format) + begin + Pakyow.app.presenter.parser_store[format].call(content) + rescue + Log.warn("No parser defined for extension #{format}") unless format.to_sym == :html + content end - - if self.doc.fragment? || self.doc.element? - self.doc.add_previous_sibling(content) - else - self.doc.xpath("//head/*[1]").before(content) - end end - def remove_resource(*args) - type, resource, options = args - options ||= {} - - case type - when :js then self.doc.css("script[src='#{Pakyow::Configuration::Presenter.javascripts}/#{resource}.js']").remove - when :css then self.doc.css("link[href='#{Pakyow::Configuration::Presenter.stylesheets}/#{resource}.css']").remove - end - end - - def find(element) - group = Views.new - @doc.css(element).each {|e| group << View.new(e)} - - return group - end - - def in_context(&block) - ViewContext.new(self).instance_exec(self, &block) - end - - def bind(object, opts = {}) - bind_as = opts[:to] ? opts[:to].to_s : StringUtils.underscore(object.class.name.split('::').last) - - @doc.traverse do |o| - if attribute = o.get_attribute('itemprop') - selector = attribute - elsif attribute = o.get_attribute('name') - selector = attribute - else - next - end - - next unless attribute - - type_len = bind_as.length - next if selector[0, type_len + 1] != "#{bind_as}[" - - attribute = selector[type_len + 1, attribute.length - type_len - 2] - - binding = { - :element => o, - :attribute => attribute.to_sym, - :selector => selector - } - - bind_object_to_binding(object, binding, bind_as) - end - end - - def repeat_for(objects, opts = {}, &block) - if o = @doc - objects.each do |object| - view = View.new(self) - view.bind(object, opts) - ViewContext.new(view).instance_exec(object, view, &block) if block_given? - - o.add_previous_sibling(view.doc) - end - - o.remove - end - end - - def reset_container(container) - return unless @doc - return unless o = @doc.css("*[id='#{container}']").first - return if o.blank? - - o.inner_html = '' - end - def title=(title) if @doc if o = @doc.css('title').first o.inner_html = Nokogiri::HTML::fragment(title) else @@ -156,11 +109,11 @@ o.inner_html if o end def to_html(container = nil) if container - if o = @doc.css('#' + container.to_s).first + if o = @doc.css("*[#{Configuration::Presenter.container_attribute}='#{container}']").first o.inner_html else '' end else @@ -173,37 +126,47 @@ # Allows multiple attributes to be set at once. # root_view.find(selector).attributes(:class => my_class, :style => my_style) # def attributes(*args) if args.empty? - @previous_method = :attributes - return self + return Attributes.new(self) else - args[0].each_pair { |name, value| - @previous_method = :attributes - self.send(name.to_sym, value) - } + #TODO mass assign attributes (if we still want to do this) + #TODO use this instead of (or combine with) bind_attributes_to_doc? end + + # if args.empty? + # @previous_method = :attributes + # return self + # else + # args[0].each_pair { |name, value| + # @previous_method = :attributes + # self.send(name.to_sym, value) + # } + # end end + + alias :attrs :attributes def remove self.doc.remove end alias :delete :remove - def add_class(val) - self.doc['class'] = "#{self.doc['class']} #{val}".strip - end + #TODO replace this with a different syntax (?): view.attributes.class.add/remove/has?(:foo) + # def add_class(val) + # self.doc['class'] = "#{self.doc['class']} #{val}".strip + # end - def remove_class(val) - self.doc['class'] = self.doc['class'].gsub(val.to_s, '').strip if self.doc['class'] - end + # def remove_class(val) + # self.doc['class'] = self.doc['class'].gsub(val.to_s, '').strip if self.doc['class'] + # end - def has_class(val) - self.doc['class'].include? val - end + # def has_class(val) + # self.doc['class'].include? val + # end def clear return if self.doc.blank? self.doc.inner_html = '' end @@ -219,165 +182,399 @@ alias :html :content def content=(content) self.doc.inner_html = Nokogiri::HTML.fragment(content.to_s) end - + alias :html= :content= - def append(content) - self.doc.add_child(Nokogiri::HTML.fragment(content.to_s)) + def append(view) + self.doc.add_child(view.doc) end - alias :render :append - - def method_missing(method, *args) - return unless @previous_method == :attributes - @previous_method = nil - - if method.to_s.include?('=') - attribute = method.to_s.gsub('=', '') - value = args[0] + def after(view) + self.doc.after(view.doc) + end + + def before(view) + self.doc.before(view.doc) + end + + def scope(name) + name = name.to_sym - if value.is_a? Proc - value = value.call(self.doc[attribute]) - end + views = ViewCollection.new + self.bindings.select{|b| b[:scope] == name}.each{|s| + v = self.view_from_path(s[:path]) + v.bindings = self.bindings_for_child_view(v) + v.scoped_as = s[:scope] - if value.nil? - self.doc.remove_attribute(attribute) - else - self.doc[attribute] = value - end - else - return self.doc[method.to_s] - end + views << v + } + + views end - def class(*args) - if @previous_method == :attributes - method_missing(:class, *args) - else - super - end + def prop(name) + name = name.to_sym + + views = ViewCollection.new + self.bindings.each {|binding| + binding[:props].each {|prop| + if prop[:prop] == name + v = self.view_from_path(prop[:path]) + v.bindings = self.bindings_for_child_view(v) + + views << v + end + } + } + + views end - - def id - if @previous_method == :attributes - method_missing(:id) - else - super - end + + # call-seq: + # with {|view| block} + # + # Creates a context in which view manipulations can be performed. + # + # Unlike previous versions, the context can only be referenced by the + # block argument. No `context` method will be available.s + # + def with + yield(self) end - def elements_with_ids + # call-seq: + # for {|view, datum| block} + # + # Yields a view and its matching dataum. This is driven by the view, + # meaning datums are yielded until no more views are available. For + # the single View case, only one view/datum pair is yielded. + # + # (this is basically Bret's `map` function) + # + def for(data, &block) + data = [data] unless data.instance_of?(Array) + block.call(self, data[0]) + end + + # call-seq: + # match(data) => ViewCollection + # + # Returns a ViewCollection object that has been manipulated to match the data. + # For the single View case, the ViewCollection collection will consist n copies + # of self, where n = data.length. + # + def match(data) + data = [data] unless data.instance_of?(Array) + + views = ViewCollection.new + data.each {|datum| + d_v = self.doc.dup + self.doc.before(d_v) + + v = View.new(d_v) + v.bindings = self.bindings + #TODO set view scope + + views << v + } + + self.remove + views + end + + # call-seq: + # repeat(data) {|view, datum| block} + # + # Matches self with data and yields a view/datum pair. + # + def repeat(data, &block) + self.match(data).for(data, &block) + end + + # call-seq: + # bind(data) + # + # Binds data across existing scopes. + # + def bind(data, bindings = nil, &block) + scope = self.bindings.first + + binder = View.binder_for_scope(scope[:scope], data) + binder.merge(bindings) + + self.bind_data_to_scope(data, scope, binder) + yield(self, data) if block_given? + end + + # call-seq: + # apply(data) + # + # Matches self to data then binds data to the view. + # + def apply(data, bindings = nil, &block) + views = self.match(data).bind(data, bindings, &block) + end + + def container(name) + matches = self.containers.select{|c| c[:name].to_sym == name.to_sym} + + vs = ViewCollection.new + matches.each{|m| vs << view_from_path(m[:path])} + vs + end + + def containers + @containers ||= self.find_containers + end + + def bindings + @bindings ||= self.find_bindings + end + + protected + + def add_content_to_container(content, container) + content = content.doc unless content.class == String || content.class == Nokogiri::HTML::DocumentFragment || content.class == Nokogiri::XML::Element + container.add_child(content) + end + + def reset_container(container) + container.inner_html = '' + end + + + # populates the root_view using view_store data by recursively building + # and substituting in child views named in the structure + def populate_view(root_view, view_info) + root_view.containers.each {|e| + next unless path = view_info[e[:name]] + + v = self.populate_view(View.new(path), view_info) + self.reset_container(e[:doc]) + self.add_content_to_container(v, e[:doc]) + } + root_view + end + + # returns an array of hashes, each with the container name and doc + def find_containers elements = [] @doc.traverse {|e| - if e.has_attribute?("id") - elements << e + if name = e.attr(Configuration::Presenter.container_attribute) + elements << { :name => name, :doc => e, :path => path_to(e)} end } elements end - protected + # returns an array of hashes that describe each scope + def find_bindings + bindings = [] + breadth_first(@doc) {|o| + next unless scope = o[Configuration::Presenter.scope_attribute] - def bind_object_to_binding(object, binding, bind_as) - binder = nil - - if View.binders - b = View.binders[bind_as.to_sym] and binder = b.new(object, binding[:element]) - end - - if binder && binder.class.method_defined?(binding[:attribute]) - value = binder.send(binding[:attribute]) - else - if object.is_a? Hash - value = object[binding[:attribute]] - else - if Configuration::Base.app.dev_mode == true && !object.class.method_defined?(binding[:attribute]) - Log.warn("Attempting to bind object to #{binding[:html_tag]}#{binding[:selector].gsub('*', '').gsub('\'', '')} but #{object.class.name}##{binding[:attribute]} is not defined.") - return - else - value = object.send(binding[:attribute]) - end - end - end - - if value.is_a? Hash - value.each do |k, v| - if v.is_a? Proc - v = v.call(binding[:element][k.to_s]) - end + # find props + props = [] + breadth_first(o) {|so| + # don't go into deeper scopes + throw :reject if so != o && so[Configuration::Presenter.scope_attribute] + + next unless prop = so[Configuration::Presenter.prop_attribute] + props << {:prop => prop.to_sym, :path => path_to(so)} + } + + bindings << {:scope => scope.to_sym, :path => path_to(o), :props => props} + } + + # determine nestedness (currently unused; leaving in case needed) + # bindings.each {|b| + # nested = [] + # bindings.each {|b2| + # b_doc = doc_from_path(b[:path]) + # b2_doc = doc_from_path(b2[:path]) + # nested << b2 if b2_doc.ancestors.include? b_doc + # } + + # b[:nested_scopes] = nested + # } + return bindings + end + + def bindings_for_child_view(child) + child_path = self.path_to(child.doc) + child_path_len = child_path.length + child_bindings = [] + + self.bindings.each {|binding| + # we want paths within the child path + if (child_path - binding[:path]).empty? + # update paths relative to child + dup = Marshal.load(Marshal.dump(binding)) - if v.nil? - binding[:element].remove_attribute(k.to_s) - elsif k == :content - bind_value_to_binding(v, binding, binder) - else - binding[:element][k.to_s] = v.to_s - end + [dup].concat(dup[:props]).each{|p| + p[:path] = p[:path][child_path_len..-1] + } + + child_bindings << dup end - else - bind_value_to_binding(value, binding, binder) + } + + child_bindings + end + + def breadth_first(doc) + queue = [doc] + until queue.empty? + node = queue.shift + catch(:reject) { + yield node + queue.concat(node.children) + } end end - def bind_value_to_binding(value, binding, binder) - if !self.self_closing_tag?(binding[:element].name) - if binding[:element].name == 'select' - if binder - if options = binder.fetch_options_for(binding[:attribute]) - html = '' - is_group = false + def path_to(child) + path = [] - options.each do |opt| - if opt.is_a?(Array) - if opt.first.is_a?(Array) - opt.each do |opt2| - html << '<option value="' + opt2[0].to_s + '">' + opt2[1].to_s + '</option>' - end - else - html << '<option value="' + opt[0].to_s + '">' + opt[1].to_s + '</option>' - end - else - html << "</optgroup>" if is_group - html << '<optgroup label="' + opt.to_s + '">' - is_group = true - end - end + return path if child == @doc - html << "</optgroup>" if is_group + child.ancestors.each {|a| + # since ancestors goes all the way to doc root, stop when we get to the level of @doc + break if a.children.include?(@doc) - binding[:element].inner_html = Nokogiri::HTML::fragment(html) - end - end + path.unshift(a.children.index(child)) + child = a + } - if opt = binding[:element].css('option[value="' + value.to_s + '"]').first - opt['selected'] = 'selected' - end + return path + end + + def doc_from_path(path) + o = @doc + + # if path is empty we're at self + return o if path.empty? + + path.each {|i| + if child = o.children[i] + o = child else - binding[:element].inner_html = Nokogiri::HTML.fragment(value.to_s) + break end - elsif binding[:element].name == 'input' && binding[:element][:type] == 'checkbox' - if value == true || (binding[:element].attributes['value'] && binding[:element].attributes['value'].value == value.to_s) - binding[:element]['checked'] = 'checked' - else - binding[:element].delete('checked') + } + + return o + end + + def view_from_path(path) + View.new(doc_from_path(path)) + end + + def bind_data_to_scope(data, scope, binder = nil) + return unless data + + # handle root binding + if binder && v = binder.value_for_prop(:_root) + v.is_a?(Hash) ? self.bind_attributes_to_doc(v, self.doc) : self.bind_value_to_doc(v, self.doc) + end + + scope[:props].each {|p| + k = p[:prop] + v = binder ? binder.value_for_prop(k) : data[k] + + doc = doc_from_path(p[:path]) + + # handle form field + self.bind_to_form_field(doc, scope, k, v, binder) if View.form_field?(doc.name) + + # bind attributes or value + v.is_a?(Hash) ? self.bind_attributes_to_doc(v, doc) : self.bind_value_to_doc(v, doc) + } + end + + def bind_value_to_doc(value, doc) + return unless value + + tag = doc.name + return if View.tag_without_value?(tag) + View.self_closing_tag?(tag) ? doc['value'] = value : doc.inner_html = value + end + + def bind_attributes_to_doc(attrs, doc) + attrs.each do |attr, v| + if attr == :content + v = v.call(doc.inner_html) if v.is_a?(Proc) + bind_value_to_doc(v, doc) + next end - elsif binding[:element].name == 'input' && binding[:element][:type] == 'radio' - if binding[:element].attributes['value'].value == value.to_s - binding[:element]['checked'] = 'checked' + + attr = attr.to_s + v = v.call(doc[attr]) if v.is_a?(Proc) + v.nil? ? doc.remove_attribute(attr) : doc[attr] = v.to_s + end + end + + #TODO refactor to use new options_for + def bind_to_form_field(doc, scope, prop, value, binder) + return unless !doc['name'] || doc['name'].empty? + + # set name on form element + doc['name'] = "#{scope[:scope]}[#{prop}]" + + # special binding for checkboxes and radio buttons + if doc.name == 'input' && (doc[:type] == 'checkbox' || doc[:type] == 'radio') + if value == true || (doc[:value] && doc[:value] == value.to_s) + doc[:checked] = 'checked' else - binding[:element].delete('checked') + doc.delete('checked') end - else - binding[:element]['value'] = value.to_s + + # coerce to string since booleans are often used + # and fail when binding to a view + value = value.to_s + # special binding for selects + elsif doc.name == 'select' && binder && options = binder.options_for_prop(prop) + option_nodes = Nokogiri::HTML::DocumentFragment.parse "" + Nokogiri::HTML::Builder.with(option_nodes) do |h| + until options.length == 0 + catch :optgroup do + options.each_with_index { |o,i| + + # an array containing value/content + if o.is_a?(Array) + h.option o[1], :value => o[0] + options.delete_at(i) + # likely an object (e.g. string); start a group + else + h.optgroup(:label => o) { + options.delete_at(i) + + options[i..-1].each_with_index { |o2,i2| + # starting a new group + throw :optgroup if !o2.is_a?(Array) + + h.option o2[1], :value => o2[0] + options.delete_at(i) + } + } + end + + } + end + end + end + + doc.add_child(option_nodes) end + + # select appropriate option + if o = doc.css('option[value="' + value.to_s + '"]').first + o[:selected] = 'selected' + end end - - def self_closing_tag?(tag) - %w[area base basefont br hr input img link meta].include? tag - end - + end end end