pakyow-presenter/lib/presenter/view.rb in pakyow-presenter-0.8.0 vs pakyow-presenter/lib/presenter/view.rb in pakyow-presenter-0.9.0

- old
+ new

@@ -1,232 +1,135 @@ +require 'forwardable' + module Pakyow module Presenter class View - include DocHelpers - include TitleHelpers + extend Forwardable - PARTIAL_REGEX = /<!--\s*@include\s*([a-zA-Z0-9\-_]*)\s*-->/ + def_delegators :@doc, :title=, :title, :remove, :clear, :text, :html - class << self - attr_accessor :binders + # The object responsible for parsing, manipulating, and rendering + # the underlying HTML document for the view. + # + attr_reader :doc - def self_closing_tag?(tag) - %w[area base basefont br hr input img link meta].include? tag - end + # The scope, if any, that the view belongs to. + # + attr_accessor :scoped_as - def form_field?(tag) - %w[input select textarea button].include? tag - end - - def tag_without_value?(tag) - %w[select].include? tag - end + # Creates a view, running `contents` through any registered view processors for `format`. + # + # @param contents [String] the contents of the view + # @param format [Symbol] the format of contents + # + def initialize(contents = '', format: :html) + @doc = Config.presenter.view_doc_class.new(Presenter.process(contents, format)) end - attr_accessor :doc, :scoped_as, :scopes, :related_views, :context, :composer - attr_writer :bindings - - def initialize(contents = '', format = :html) - @related_views = [] - - processed = Presenter.process(contents, format) - - if processed.match(/<html.*>/) - @doc = Nokogiri::HTML::Document.parse(processed) - else - @doc = Nokogiri::HTML.fragment(processed) - end - end - def initialize_copy(original_view) super @doc = original_view.doc.dup @scoped_as = original_view.scoped_as - @context = @context - @composer = @composer end + # Creates a new view with a soft copy of doc. + # + def soft_copy + copy = View.from_doc(@doc.soft_copy) + copy.scoped_as = scoped_as + copy + end + + # Creates a view from a doc. + # + # @see StringDoc + # @see NokogiriDoc + # def self.from_doc(doc) - view = self.new - view.doc = doc - return view + view = new + view.instance_variable_set(:@doc, doc) + view end + # Creates a view from a file. + # def self.load(path) - format = Utils::String.split_at_last_dot(path)[-1] - contents = File.read(path) + new(File.read(path), format: File.format(path)) + end - return self.new(contents, format) + def ==(other) + self.class == other.class && @doc == other.doc end # Allows multiple attributes to be set at once. - # root_view.find(selector).attributes(:class => my_class, :style => my_style) # - def attributes(attrs = {}) - #TODO this is not invalidating composer - - if attrs.empty? - return Attributes.new(self.doc, @composer) - else - self.bind_attributes_to_doc(attrs, doc) - end + # view.attrs(class: '...', style: '...') + # + def attrs(attrs = {}) + return Attributes.new(@doc) if attrs.empty? + bind_attributes_to_doc(attrs, @doc) end - alias :attrs :attributes - - def remove - if doc.parent.nil? - # best we can do is to remove the children - doc.children.remove - else - doc.remove - end - - invalidate! - end - - alias :delete :remove - - def clear - return if self.doc.blank? - self.doc.inner_html = '' - self.invalidate! - end - - def text - self.doc.inner_text - end - def text=(text) text = text.call(self.text) if text.is_a?(Proc) - self.doc.content = text.to_s - self.invalidate! + @doc.text = text end - def html - self.doc.inner_html - end - def html=(html) html = html.call(self.html) if html.is_a?(Proc) - self.doc.inner_html = Nokogiri::HTML.fragment(html.to_s) - self.invalidate! + @doc.html = html end def append(view) - doc = view.doc - num = doc.children.count - path = self.path_to(doc) - - self.doc.add_child(view.doc) - - self.update_binding_offset_at_path(num, path) - self.invalidate! + @doc.append(view.doc) end def prepend(view) - doc = view.doc - num = doc.children.count - path = self.path_to(doc) - - if first_child = self.doc.children.first - first_child.add_previous_sibling(doc) - else - self.doc = doc - end - - self.update_binding_offset_at_path(num, path) - self.invalidate! + @doc.prepend(view.doc) end + #TODO allow strings? def after(view) - doc = view.doc - num = doc.children.count - path = self.path_to(doc) - - self.doc.after(view.doc) - - self.update_binding_offset_at_path(num, path) - self.invalidate! + @doc.after(view.doc) end def before(view) - doc = view.doc - num = doc.children.count - path = self.path_to(doc) - - self.doc.before(view.doc) - - self.update_binding_offset_at_path(num, path) - self.invalidate! + @doc.before(view.doc) end def replace(view) - view = view.doc if view.is_a?(View) - - if doc.parent.nil? - doc.children.remove - doc.inner_html = view - else - doc.replace(view) - end - - invalidate! + replacement = view.is_a?(View) ? view.doc : view + @doc.replace(replacement) end def scope(name) name = name.to_sym - - views = ViewCollection.new - views.context = @context - views.composer = @composer - self.bindings.select{|b| b[:scope] == name}.each{|s| - v = self.view_from_path(s[:path]) - - v.bindings = self.update_binding_paths_from_path([s].concat(s[:nested_bindings]), s[:path]) - v.scoped_as = s[:scope] - v.context = @context - v.composer = @composer - - views << v - } - - views + @doc.scope(name).inject(ViewCollection.new) do |coll, scope| + view = View.from_doc(scope[:doc]) + view.scoped_as = name + coll << view + end end def prop(name) name = name.to_sym - - views = ViewCollection.new - views.context = @context - views.composer = @composer - - if binding = self.bindings.select{|binding| binding[:scope] == self.scoped_as}[0] - binding[:props].each {|prop| - if prop[:prop] == name - v = self.view_from_path(prop[:path]) - - v.scoped_as = self.scoped_as - v.context = @context - v.composer = @composer - views << v - end - } + @doc.prop(scoped_as, name).inject(ViewCollection.new) do |coll, prop| + view = View.from_doc(prop[:doc]) + view.scoped_as = scoped_as + coll << view end - - views end # call-seq: # with {|view| block} # # Creates a context in which view manipulations can be performed. # def with(&block) if block.arity == 0 - self.instance_exec(&block) + instance_exec(&block) else yield(self) end self @@ -240,27 +143,25 @@ # the single View case, only one view/datum pair is yielded. # # (this is basically Bret's `map` function) # def for(data, &block) - data = data.to_a if data.is_a?(Enumerator) - data = [data] if (!data.is_a?(Enumerable) || data.is_a?(Hash)) - + datum = Array.ensure(data).first if block.arity == 1 - self.instance_exec(data[0], &block) + instance_exec(datum, &block) else - block.call(self, data[0]) + block.call(self, datum) end end # call-seq: # for_with_index {|view, datum, i| block} # # Yields a view, its matching dataum, and the index. See #for. # def for_with_index(data, &block) - self.for(data) do |ctx, datum| + self.for(data) do |ctx, datum| if block.arity == 2 ctx.instance_exec(datum, 0, &block) else block.call(ctx, datum, 0) end @@ -273,81 +174,77 @@ # 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.to_a if data.is_a?(Enumerator) - data = [data] if (!data.is_a?(Enumerable) || data.is_a?(Hash)) + data = Array.ensure(data) + coll = ViewCollection.new - views = ViewCollection.new - views.context = @context - views.composer = @composer - data.each {|datum| - d_v = self.doc.dup - self.doc.before(d_v) + # an empty set always means an empty view + if data.empty? + remove + else + # dup for later + original_view = dup if data.length > 1 - v = View.from_doc(d_v) - v.bindings = self.bindings.dup - v.scoped_as = self.scoped_as - v.context = @context - v.composer = @composer + # the original view match the first datum + coll << self - views << v - } + # create views for the other datums + data[1..-1].inject(coll) { |coll| + duped_view = original_view.dup + after(duped_view) + coll << duped_view + } + end - self.remove - views + # return the new collection + coll 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) + match(data).for(data, &block) end # call-seq: # repeat_with_index(data) {|view, datum, i| block} # # Matches self with data and yields a view/datum pair with index. # def repeat_with_index(data, &block) - self.match(data).for_with_index(data, &block) + match(data).for_with_index(data, &block) end # call-seq: # bind(data) # - # Binds data across existing scopes. + # Binds a single datum across existing scopes. # - def bind(data, bindings = {}, &block) - data = data.to_a if data.is_a?(Enumerator) - data = [data] if (!data.is_a?(Enumerable) || data.is_a?(Hash)) - - scope_info = self.bindings.first - - self.bind_data_to_scope(data[0], scope_info, bindings) - invalidate!(true) - + def bind(data, bindings: {}, context: nil, &block) + datum = Array.ensure(data).first + bind_data_to_scope(datum, doc.scopes.first, bindings, context) return if block.nil? if block.arity == 1 - self.instance_exec(data[0], &block) + instance_exec(datum, &block) else - block.call(self, data[0]) + block.call(self, datum) end end # call-seq: # bind_with_index(data) # # Binds data across existing scopes, yielding a view/datum pair with index. # - def bind_with_index(data, bindings = {}, &block) - self.bind(data) do |ctx, datum| + def bind_with_index(*a, **k, &block) + bind(*a, **k) do |ctx, datum| if block.arity == 2 ctx.instance_exec(datum, 0, &block) else block.call(ctx, datum, 0) end @@ -357,335 +254,206 @@ # call-seq: # apply(data) # # Matches self to data then binds data to the view. # - def apply(data, bindings = {}, &block) - self.match(data).bind(data, bindings, &block) + def apply(data, bindings: {}, context: nil, &block) + match(data).bind(data, bindings: bindings, context: context, &block) end - def bindings(refind = false) - @bindings = (!@bindings || refind) ? self.find_bindings : @bindings - end - def includes(partial_map) + partials = @doc.partials partial_map = partial_map.dup # mixin all the partials - partials.each do |partial| - partial[1].replace(partial_map[partial[0]].to_s) + partials.each do |partial_info| + partial = partial_map[partial_info[0]] + next if partial.nil? + partial_info[1].replace(partial.doc.dup) end - # now delete them from the map - partials.each do |partial| - partial_map.delete(partial[0]) - end + # refind the partials + partials = @doc.partials - # we have more partials - if partial_map.count > 0 - # initiate another build if content contains partials - includes(partial_map) if partials(true).count > 0 - end + # if mixed in partials included partials, we want to run includes again with a new map + if partials.count > 0 && (partial_map.keys - partials.keys).count < partial_map.keys.count + includes(partial_map) + end - return self + self end - def invalidate!(composer_only = false) - self.bindings(true) unless composer_only - @composer.dirty! unless @composer.nil? + def to_html + @doc.to_html + end + alias :to_s :to_html - @related_views.each {|v| - v.invalidate!(composer_only) - } - end + private - protected - - def partials(refind = false) - @partials = (!@partials || refind) ? find_partials : @partials - end - - def partials_in(content) - partials = [] - - content.scan(PARTIAL_REGEX) do |m| - partials << m[0].to_sym - end - - return partials - end - - def find_partials - partials = [] - - @doc.traverse { |e| - next unless e.is_a?(Nokogiri::XML::Comment) - next unless match = e.to_html.strip.match(PARTIAL_REGEX) - - name = match[1] - partials << [name.to_sym, e] - } - - return partials - 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_store, view_info) - root_view.containers.each {|e| - next unless path = view_info[e[:name]] - - v = self.populate_view(View.new(path, view_store), view_store, view_info) - v.context = @context - v.composer = @composer - self.reset_container(e[:doc]) - self.add_content_to_container(v, e[:doc]) - } - root_view - end - - - # returns an array of hashes that describe each scope - def find_bindings(doc = @doc, ignore_root = false) - bindings = [] - breadth_first(doc) {|o| - next if o == doc && ignore_root - next if !scope = o[Config::Presenter.scope_attribute] - - bindings << { - :scope => scope.to_sym, - :path => path_to(o), - :props => find_props(o) - } - - if o == doc - # this is the root node, which we need as the first hash in the - # list of bindings, but we don't want to nest other scopes inside - # of it in this case - bindings.last[:nested_bindings] = [] - else - bindings.last[:nested_bindings] = find_bindings(o, true) - # reject so children aren't traversed - throw :reject - end - } - - # find unscoped props - unless doc[Config::Presenter.scope_attribute] - bindings.unshift({ - :scope => nil, - :path => [], - :props => find_props(doc), - :nested_bindings => [] - }) - end - - return bindings - end - - def find_props(o) - props = [] - breadth_first(o) {|so| - # don't go into deeper scopes - throw :reject if so != o && so[Config::Presenter.scope_attribute] - - next unless prop = so[Config::Presenter.prop_attribute] - props << {:prop => prop.to_sym, :path => path_to(so)} - } - - return props - end - - # returns a new binding set that takes into account the starting point of `path` - def update_binding_paths_from_path(bindings, path) - return bindings.collect { |binding| - dup_binding = binding.dup - dup_binding[:path] = dup_binding[:path][path.length..-1] || [] - - dup_binding[:props] = dup_binding[:props].collect {|prop| - dup_prop = prop.dup - dup_prop[:path] = dup_prop[:path][path.length..-1] - dup_prop - } - - dup_binding[:nested_bindings] = update_binding_paths_from_path(dup_binding[:nested_bindings], path) - - dup_binding - } - end - - def update_binding_offset_at_path(offset, path) - # update binding paths for bindings we're iterating on - self.bindings.each {|binding| - next unless self.path_within_path?(binding[:path], path) - - binding[:path][0] += offset if binding[:path][0] - - binding[:props].each { |prop| - prop[:path][0] += offset if prop[:path][0] - } - } - end - - def bind_data_to_scope(data, scope_info, bindings = {}) + def bind_data_to_scope(data, scope_info, bindings, ctx) return unless data + return unless scope_info scope = scope_info[:scope] + bind_data_to_root(data, scope, bindings, ctx) - bind_data_to_root(data, scope, bindings) - - scope_info[:props].each { |prop_info| - catch(:unbound) { + scope_info[:props].each do |prop_info| + catch(:unbound) do prop = prop_info[:prop] - if data_has_prop?(data, prop) || Pakyow.app.presenter.binder.has_prop?(prop, scope, bindings) - value = Pakyow.app.presenter.binder.value_for_prop(prop, scope, data, bindings, context) - doc = doc_from_path(prop_info[:path]) + if data_has_prop?(data, prop) || Binder.instance.has_scoped_prop?(scope, prop, bindings) + value = Binder.instance.value_for_scoped_prop(scope, prop, data, bindings, ctx) + doc = prop_info[:doc] - if View.form_field?(doc.name) - bind_to_form_field(doc, scope, prop, value, data) + if DocHelpers.form_field?(doc.tagname) + bind_to_form_field(doc, scope, prop, value, data, ctx) end bind_data_to_doc(doc, value) else handle_unbound_data(scope, prop) end - } - } + end + end end - def bind_data_to_root(data, scope, bindings) - return unless value = Pakyow.app.presenter.binder.value_for_prop(:_root, scope, data, bindings, context) - value.is_a?(Hash) ? self.bind_attributes_to_doc(value, self.doc) : self.bind_value_to_doc(value, self.doc) + def bind_data_to_root(data, scope, bindings, ctx) + value = Binder.instance.value_for_scoped_prop(scope, :_root, data, bindings, ctx) + return if value.nil? + + value.is_a?(Hash) ? bind_attributes_to_doc(value, doc) : bind_value_to_doc(value, doc) end def bind_data_to_doc(doc, data) - data.is_a?(Hash) ? self.bind_attributes_to_doc(data, doc) : self.bind_value_to_doc(data, doc) + data.is_a?(Hash) ? bind_attributes_to_doc(data, doc) : bind_value_to_doc(data, doc) end def data_has_prop?(data, prop) (data.is_a?(Hash) && (data.key?(prop) || data.key?(prop.to_s))) || (!data.is_a?(Hash) && data.class.method_defined?(prop)) end def bind_value_to_doc(value, doc) value = String(value) - tag = doc.name - return if View.tag_without_value?(tag) + tag = doc.tagname + return if DocHelpers.tag_without_value?(tag) - if View.self_closing_tag?(tag) + if DocHelpers.self_closing_tag?(tag) # don't override value if set - if !doc['value'] || doc['value'].empty? - doc['value'] = value + if !doc.get_attribute(:value) || doc.get_attribute(:value).empty? + doc.set_attribute(:value, value) end else - doc.inner_html = value + doc.html = value end end - def bind_attributes_to_doc(attrs, doc) - attrs.each do |attr, v| - case attr - when :content - v = v.call(doc.inner_html) if v.is_a?(Proc) - bind_value_to_doc(v, doc) - next - when :view - v.call(self) - next - end - - attr = attr.to_s - attrs = Attributes.new(doc) - v = v.call(attrs.send(attr)) if v.is_a?(Proc) - - if v.nil? - doc.remove_attribute(attr) - else - attrs.send(:"#{attr}=", v) - end - end - end - - def bind_to_form_field(doc, scope, prop, value, bindable) + def bind_to_form_field(doc, scope, prop, value, bindable, ctx) set_form_field_name(doc, scope, prop) # special binding for checkboxes and radio buttons - if doc.name == 'input' && (doc[:type] == 'checkbox' || doc[:type] == 'radio') + if doc.tagname == 'input' && (doc.get_attribute(:type) == 'checkbox' || doc.get_attribute(:type) == 'radio') bind_to_checked_field(doc, value) - # special binding for selects - elsif doc.name == 'select' - bind_to_select_field(doc, scope, prop, value, bindable) + # special binding for selects + elsif doc.tagname == 'select' + bind_to_select_field(doc, scope, prop, value, bindable, ctx) end end def bind_to_checked_field(doc, value) - if value == true || (doc[:value] && doc[:value] == value.to_s) - doc[:checked] = 'checked' + if value == true || (doc.get_attribute(:value) && doc.get_attribute(:value) == value.to_s) + doc.set_attribute(:checked, 'checked') else - doc.delete('checked') + doc.remove_attribute(:checked) end # coerce to string since booleans are often used and fail when binding to a view - value = value.to_s + value.to_s end - def bind_to_select_field(doc, scope, prop, value, bindable) - create_select_options(doc, scope, prop, value, bindable) + def bind_to_select_field(doc, scope, prop, value, bindable, ctx) + create_select_options(doc, scope, prop, value, bindable, ctx) select_option_with_value(doc, value) end def set_form_field_name(doc, scope, prop) - return if doc['name'] && !doc['name'].empty? # don't overwrite the name if already defined - doc['name'] = "#{scope}[#{prop}]" + return if doc.get_attribute(:name) && !doc.get_attribute(:name).empty? # don't overwrite the name if already defined + doc.set_attribute(:name, "#{scope}[#{prop}]") end - def create_select_options(doc, scope, prop, value, bindable) - return unless options = Pakyow.app.presenter.binder.options_for_prop(prop, scope, bindable, context) + def create_select_options(doc, scope, prop, value, bindable, ctx) + options = Binder.instance.options_for_scoped_prop(scope, prop, bindable, ctx) + return if options.nil? - option_nodes = Nokogiri::HTML::DocumentFragment.parse "" + option_nodes = Nokogiri::HTML::DocumentFragment.parse('') Nokogiri::HTML::Builder.with(option_nodes) do |h| until options.length == 0 catch :optgroup do o = options.first # an array containing value/content if o.is_a?(Array) - h.option o[1], :value => o[0] + h.option o[1], value: o[0] options.shift # likely an object (e.g. string); start a group else - h.optgroup(:label => o) { + h.optgroup(label: o) { options.shift options[0..-1].each_with_index { |o2,i2| # starting a new group - throw :optgroup if !o2.is_a?(Array) + throw :optgroup unless o2.is_a?(Array) - h.option o2[1], :value => o2[0] + h.option o2[1], value: o2[0] options.shift } } end end end end # remove existing options - doc.children.remove + doc.clear # add generated options - doc.add_child(option_nodes) + doc.append(option_nodes.to_html) end def select_option_with_value(doc, value) - return unless o = doc.css('option[value="' + value.to_s + '"]').first - o[:selected] = 'selected' + option = doc.option(value: value) + return if option.nil? + + option.set_attribute(:selected, 'selected') end def handle_unbound_data(scope, prop) - Pakyow.logger.warn("Unbound data for #{scope}[#{prop}]") + Pakyow.logger.warn("Unbound data for #{scope}[#{prop}]") if Pakyow.logger throw :unbound + end + + def bind_attributes_to_doc(attrs, doc) + attrs.each do |attr, v| + case attr + when :content + v = v.call(doc.inner_html) if v.is_a?(Proc) + bind_value_to_doc(v, doc) + next + when :view + v.call(self) + next + else + attr = attr.to_s + attrs = Attributes.new(doc) + v = v.call(attrs.send(attr)) if v.is_a?(Proc) + + if v.nil? + doc.remove_attribute(attr) + else + attrs.send(:"#{attr}=", v) + end + end + end end end end end