DFE-Digital/html-attributes-utils

View on GitHub
lib/html_attributes_utils.rb

Summary

Maintainability
A
0 mins
Test Coverage
require "active_support/core_ext/hash/keys"

module HTMLAttributesUtils
  # DEFAULT_MERGEABLE_ATTRIBUTES is a list of HTML attributes where the value
  # contain multiple elements separated by spaces. We use it to target values
  # to split so the arrays can be cleanly merged.
  #
  # They are stored as nested arrays so when we're walking multiple
  # levels on the deep merge the structure can be identified. This means
  # the library works with the Rails-preferred format of
  # `aria: { describedby: "xyz" }` rather than `"aria-describdby" => "xyz"`
  DEFAULT_MERGEABLE_ATTRIBUTES = [
    %i(class),
    %i(aria controls),
    %i(aria describedby),
    %i(aria flowto),
    %i(aria labelledby),
    %i(data aria_controls),
    %i(aria owns),
    %i(rel),
  ].freeze

  refine Hash do
    # Merge the incoming hash into the current one in a way that suits
    # HTML attributes.
    #
    # @param custom [Hash] the incoming hash
    # @param parents [Array] used to keep track of the keys that have already been
    #   merged, *don't supply this unless the method is called during recursion*
    # @mergeable_attributes [Array<Array<Symbol>>] Mergeable attributes are
    #   HTML attributes can contain lists made from space-separated strings. We
    #   convert them to arrays so they can be cleanly merged. Rails accepts them
    #   as arrays so there's no need to convert back to strings.
    #
    # @example
    #   original = { class: "red", data: { size: "medium", controller: "comment" } }
    #   incoming = { class: "blue", data: { controller: "reply" } }
    #
    #   original.deep_merge_html_attributes(incoming)
    #
    #   => { class: "blue", data: { size: "medium", controller: "reply" } }
    #
    def deep_merge_html_attributes(custom, parents = [], mergeable_attributes: DEFAULT_MERGEABLE_ATTRIBUTES)
      return custom unless custom.is_a?(Hash)

      overrides = custom.deep_symbolize_keys
      originals = deep_symbolize_keys

      originals.each_with_object(originals) { |(key, value), merged|
        next unless overrides.key?(key)

        merged[key] = process_pair(key, value, parents: parents, overrides: overrides, mergeable_attributes: mergeable_attributes)

        overrides.delete(key)
      }.merge(overrides)
    end

    # Remove unwanted attributes from a the hash and any values that are hashes
    # recursively. In particular we don't care for empty hashes, arrays,
    # strings that are empty or just contain spaces and nils.
    #
    # It preserves +true+ and +false+.
    #
    # @return [Hash] the tidied hash
    # @example
    #   { class: "blue", title: nil, lang: "", aria: { describedby: [] } }.deep_tidy_html_attributes
    #
    #   => { class: "blue" }
    #
    def deep_tidy_html_attributes
      each_with_object({}) { |(k, v), tidied| (tidied[k] = tidy_value(v)) unless v.nil? }.compact
    end

  private

    def process_pair(key, value, parents:, overrides:, mergeable_attributes:)
      combine_values(
        value,
        overrides[key],
        parents: [*parents, *key],
        mergeable_attributes: mergeable_attributes
      )
    end

    def tidy_value(value)
      case value
      when TrueClass, FalseClass then value
      when Hash                  then tidy_hash(value)
      when Array                 then tidy_array(value)
      else                            tidy_remaining(value)
      end
    end

    def tidy_hash(hash)
      return nil if hash.empty?

      hash.deep_tidy_html_attributes
    end

    def tidy_array(array)
      return nil if array.empty?

      array.map { |v| tidy_value(v) }.compact
    end

    def tidy_remaining(value)
      value.to_s.strip.presence
    end

    def combine_values(value, override, **kwargs)
      case split_attribute_list(value, **kwargs)
      when Array
        combine_array(value, override, **kwargs)
      when Hash
        combine_hash(value, override, **kwargs)
      else
        split_attribute_list(override, **kwargs)
      end
    end

    def combine_array(originals, overrides, parents:, mergeable_attributes:)
      return overrides if overrides.nil?
      return overrides unless mergeable_attributes.include?(parents)

      (try_split(originals) + try_split(overrides)).uniq
    end

    def try_split(value)
      value.is_a?(String) ? value.split : value
    end

    def combine_hash(originals, overrides, parents:, mergeable_attributes:)
      originals.deep_merge_html_attributes(overrides, parents, mergeable_attributes: mergeable_attributes)
    end

    def split_attribute_list(value, parents:, mergeable_attributes:)
      mergeable_attributes.include?(parents) ? try_split(value) : value
    end
  end
end