# frozen_string_literal: true require "forwardable" require "pakyow/support/core_refinements/array/ensurable" require "pakyow/support/indifferentize" require "pakyow/support/inflector" require "pakyow/support/safe_string" require "string_doc" module Pakyow module Presenter # Provides an interface for manipulating view templates. # class View class << self # Creates a view from a file. # def load(path, content: nil) new(content || File.read(path)) end # Creates a view wrapping an object. # def from_object(object) instance = if object.is_a?(StringDoc::Node) && object.labeled?(:view_type) object.label(:view_type).allocate else allocate end instance.instance_variable_set(:@object, object) instance.instance_variable_set(:@info, {}) instance.instance_variable_set(:@logical_path, nil) if object.respond_to?(:attributes) instance.attributes = object.attributes else instance.instance_variable_set(:@attributes, nil) end instance end # @api private def from_view_or_string(view_or_string) case view_or_string when View, VersionedView view_or_string else View.new(Support::SafeStringHelpers.ensure_html_safety(view_or_string.to_s)) end end end include Support::SafeStringHelpers using Support::Indifferentize using Support::Refinements::Array::Ensurable extend Forwardable def_delegators :@object, :type, :text, :html, :label, :labeled? # The object responsible for transforming and rendering the underlying document or node. # # @api private attr_accessor :object # The logical path to the view template. # attr_reader :logical_path # Creates a view with +html+. # def initialize(html, info: {}, logical_path: nil) @object = StringDoc.new(html) @info, @logical_path = Support::IndifferentHash.deep(info), logical_path if @object.respond_to?(:attributes) self.attributes = @object.attributes else @attributes = nil end end def initialize_copy(_) super @info = @info.dup @object = @object.dup if @object.respond_to?(:attributes) self.attributes = @object.attributes else @attributes = nil end end # @api private def soft_copy instance = self.class.allocate instance.instance_variable_set(:@info, @info.dup) new_object = @object.soft_copy instance.instance_variable_set(:@object, new_object) if new_object.respond_to?(:attributes) instance.attributes = new_object.attributes else instance.instance_variable_set(:@attributes, nil) end instance end # Finds a view binding by name. When passed more than one value, the view will # be traversed through each name. Returns a {VersionedView}. # def find(*names) if names.any? named = names.shift.to_sym found = each_binding(named).map { |node| View.from_object(node) } result = if names.empty? && !found.empty? # found everything; wrap it up VersionedView.new(found) elsif !found.empty? && names.count > 0 # descend further found.first.find(*names) else nil end if result && block_given? yield result end result else nil end end # Finds all view bindings by name, returning an array of {View} objects. # # @api private def find_all(named) each_binding(named).map { |node| View.from_object(node) } end # Finds a form with a binding matching +name+. # def form(name) @object.each_significant_node(:form) do |form_node| return Views::Form.from_object(form_node) if form_node.label(:binding) == name end nil end # Returns all forms. # # @api private def forms @object.each_significant_node(:form, descend: true).map { |node| Views::Form.from_object(node) } end # Finds a component matching +name+. # def component(name, renderable: false) name = name.to_sym components(renderable: renderable).find { |component| component.object.label(:components).any? { |possible_component| possible_component[:name] == name } } end # Returns all components. # # @api private def components(renderable: false) @object.each_significant_node_without_descending_into_type(:component, descend: true).select { |node| !renderable || node.label(:components).any? { |component| component[:renderable] } }.map { |node| View.from_object(node) } end # Returns all view info when +key+ is +nil+, otherwise returns the value for +key+. # def info(key = nil) if key.nil? @info else @info.fetch(key, nil) end end # Returns a view for the ++ node. # def head if head_node = @object.find_first_significant_node(:head) View.from_object(head_node) else nil end end # Returns a view for the ++ node. # def body if body_node = @object.find_first_significant_node(:body) View.from_object(body_node) else nil end end # Returns a view for the ++ node. # def title if title_node = @object.find_first_significant_node(:title) View.from_object(title_node) else nil end end # Yields +self+. # def with tap do yield self end end # Transforms +self+ to match structure of +object+. # def transform(object) tap do if object.nil? || (object.respond_to?(:empty?) && object.empty?) remove else removals = [] each_binding_prop(descend: false) do |binding| binding_name = if binding.significant?(:multipart_binding) binding.label(:binding_prop) else binding.label(:binding) end unless object.present?(binding_name) removals << binding end end removals.each(&:remove) end yield self, object if block_given? end end # Binds a single object. # def bind(object) tap do unless object.nil? each_binding_prop do |binding| binding_name = if binding.significant?(:multipart_binding) binding.label(:binding_prop) else binding.label(:binding) end if object.include?(binding_name) value = if object.is_a?(Binder) object.__content(binding_name, binding) else object[binding_name] end bind_value_to_node(value, binding) binding.set_label(:bound, true) end end attributes[:"data-id"] = object[:id] self.object.set_label(:bound, true) end end end # Appends a view or string to +self+. # def append(view_or_string) tap do @object.append(self.class.from_view_or_string(view_or_string).object) end end # Prepends a view or string to +self+. # def prepend(view_or_string) tap do @object.prepend(self.class.from_view_or_string(view_or_string).object) end end # Inserts a view or string after +self+. # def after(view_or_string) tap do @object.after(self.class.from_view_or_string(view_or_string).object) end end # Inserts a view or string before +self+. # def before(view_or_string) tap do @object.before(self.class.from_view_or_string(view_or_string).object) end end # Replaces +self+ with a view or string. # def replace(view_or_string) tap do @object.replace(self.class.from_view_or_string(view_or_string).object) end end # Removes +self+. # def remove tap do @object.remove end end # Removes +self+'s children. # def clear tap do @object.clear end end # Safely sets the html value of +self+. # def html=(html) @object.html = ensure_html_safety(html.to_s) end # Returns true if +self+ is a binding. # def binding? @object.significant?(:binding) end # Returns true if +self+ is a container. # def container? @object.significant?(:container) end # Returns true if +self+ is a partial. # def partial? @object.significant?(:partial) end # Returns true if +self+ is a form. # def form? @object.significant?(:form) end # Returns true if +self+ equals +other+. # def ==(other) other.is_a?(self.class) && @object == other.object end # Returns attributes object for +self+. # def attributes @attributes end alias attrs attributes # Wraps +attributes+ in a {Attributes} instance. # def attributes=(attributes) @attributes = Attributes.new(attributes) end alias attrs= attributes= # Returns the version name for +self+. # def version (label(:version) || VersionedView::DEFAULT_VERSION).to_sym end # Converts +self+ to html, rendering the view. # def to_html @object.to_html end alias :to_s :to_html # @api private def binding_name label(:binding) end # @api private def singular_binding_name label(:singular_binding) end # @api private def plural_binding_name label(:plural_binding) end # @api private def channeled_binding_name label(:channeled_binding) end # @api private def plural_channeled_binding_name label(:plural_channeled_binding) end # @api private def singular_channeled_binding_name label(:singular_channeled_binding) end # @api private def each_binding_scope(descend: false) return enum_for(:each_binding_scope, descend: descend) unless block_given? method = if descend :each_significant_node else :each_significant_node_without_descending_into_type end @object.send(method, :binding, descend: descend) do |node| if binding_scope?(node) yield node end end end # @api private def each_binding_prop(descend: false) return enum_for(:each_binding_prop, descend: descend) unless block_given? if (@object.is_a?(StringDoc::Node) || @object.is_a?(StringDoc::MetaNode)) && @object.significant?(:multipart_binding) yield @object else method = if descend :each_significant_node else :each_significant_node_without_descending_into_type end @object.send(method, :binding, descend: descend) do |node| if binding_prop?(node) yield node end end end end # @api private def each_binding(name) return enum_for(:each_binding, name) unless block_given? each_binding_scope do |node| if node.label(:channeled_binding) == name yield node end end each_binding_prop do |node| if (node.significant?(:multipart_binding) && node.label(:binding_prop) == name) || (!node.significant?(:multipart_binding) && node.label(:binding) == name) yield node end end end # @api private def binding_scopes(descend: false) each_binding_scope(descend: descend).map(&:itself) end # @api private def binding_props(descend: false) each_binding_prop(descend: descend).map(&:itself) end # @api private def binding_scope?(node) node.significant?(:binding) && (node.significant?(:binding_within) || node.significant?(:multipart_binding) || node.label(:version) == :empty) end # @api private def binding_prop?(node) node.significant?(:binding) && node.label(:version) != :empty && (!node.significant?(:binding_within) || node.significant?(:multipart_binding)) end # @api private def find_partials(partials, found = []) found.tap do @object.each_significant_node(:partial, descend: true) do |node| if replacement = partials[node.label(:partial)] found << node.label(:partial) replacement.find_partials(partials, found) end end end end # @api private def mixin(partials) tap do @object.each_significant_node(:partial, descend: true) do |partial_node| if replacement = partials[partial_node.label(:partial)] partial_node.replace(replacement.mixin(partials).object) end end end end # Thanks Dan! https://stackoverflow.com/a/30225093 # @api private INFO_MERGER = proc { |_, v1, v2| Support::IndifferentHash === v1 && Support::IndifferentHash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 } # @api private def add_info(*infos) tap do infos.each do |info| @info.merge!(Support::IndifferentHash.deep(info), &INFO_MERGER) end end end # @api private def channeled_binding_scope?(scope) binding_scopes.select { |node| node.label(:binding) == scope }.any? { |node| node.label(:channel).any? } end private def bind_value_to_node(value, node) tag = node.tagname unless StringDoc::Node.without_value?(tag) value = String(value) if StringDoc::Node.self_closing?(tag) node.attributes[:value] = ensure_html_safety(value) if node.attributes[:value].nil? else node.html = ensure_html_safety(value) end end end end end end