# frozen_string_literal: true require_relative "hot_module/version" require "nokogiri" # Include this module into your own component class module HoTModuLe class Error < StandardError; end AttributeBinding = Struct.new(:matcher, :method_name, :method, keyword_init: true) # rubocop:disable Lint/StructNewOverride require_relative "hot_module/fragment" require_relative "hot_module/query_selection" # @param klass [Class] # @return [void] def self.included(klass) klass.extend ClassMethods # Don't stomp on a superclass's `content` method return if klass.instance_methods.include?(:content) klass.include ContentMethod end # Extends the component class module ClassMethods def html_file_extensions = %w[tmpl.html html].freeze def processed_css_extension = "css-local" # @param tag_name [String] # @param html_module [String] if not provided, a class method called `source_location` must be # available with the absolute path of the Ruby file # @param shadow_root [Boolean] default is true # @return [void] def custom_element(tag_name, html_module = nil, shadow_root: true) # rubocop:disable Metrics/AbcSize if html_module.nil? && !respond_to?(:source_location) raise HoTModuLe::Error, "You must either supply a file path argument or respond to `source_location'" end @tag_name = tag_name if html_module @html_module = html_module else basepath = File.join(File.dirname(source_location), File.basename(source_location, ".*")) @html_module = html_file_extensions.lazy.filter_map do |ext| path = "#{basepath}.#{ext}" File.exist?(path) ? path : nil end.first raise HoTModuLe::Error, "Cannot find sidecar HTML module for `#{self}'" unless @html_module end @shadow_root = shadow_root end # @param value [String] # @return [String] def tag_name(value = nil) @tag_name ||= value end # @param value [String] # @return [String] def html_module(value = nil) @html_module ||= value end # @param value [Boolean] # @return [Boolean] def shadow_root(value = nil) @shadow_root ||= value end # @return [Nokogiri::XML::Element] def doc @doc ||= Nokogiri::HTML5.fragment( "<#{tag_name}>#{File.read(html_module)}" ).first_element_child end def attribute_bindings @attribute_bindings ||= [] end def attribute_binding(matcher, method_name) attribute_bindings << AttributeBinding.new( matcher: Regexp.new(matcher), method_name: method_name ) end end module ContentMethod # @return [String, Nokogiri::XML::Element] def content; end end # Override in component # # @return [Hash] def attributes {} end # @param attributes [Hash] # @param content [String, Nokogiri::XML::Element] # @param return_node [Boolean] def render_element(attributes: self.attributes, content: self.content, return_node: false) # rubocop:disable Metrics doc = self.class.doc.clone tmpl_el = doc.css("> template").find { _1.attributes.length.zero? } unless tmpl_el tmpl_el = doc.document.create_element("template") immediate_children = doc.css("> :not(style):not(script)") tmpl_el << immediate_children doc.prepend_child(tmpl_el) end # Process all the template bits process_fragment(tmpl_el) # Set attributes on the custom element attributes.each { |k, v| doc[k.to_s.tr("_", "-")] = v } # Look for external and internal styles output_styles = "" external_styles = doc.css("link[rel=stylesheet]") external_styles.each do |external_style| next unless external_style["hmod:process"] output_styles += File.read(File.expand_path(external_style["href"], File.dirname(self.class.html_module))) external_style.remove rescue StandardError => e raise e.class, e.message.lines.first, ["#{self.class.html_module}:#{external_style.line}", *e.backtrace] end sidecar_file = "#{File.join( File.dirname(self.class.html_module), File.basename(self.class.html_module, ".*") )}.#{self.class.processed_css_extension}" output_styles += if File.exist?(sidecar_file) File.read(sidecar_file) else doc.css("> style:not([scope])").map(&:content).join end # Now remove all nodes *except* the template doc.children.each do |node| node.remove unless node == tmpl_el end style_tag = nil if output_styles.length.positive? # We'll transfer everything over to a single style element style_tag = tmpl_el.document.create_element("style") style_tag.content = output_styles end if self.class.shadow_root # Guess what? We can reuse the same template tag! =) tmpl_el["shadowroot"] = "open" tmpl_el << style_tag if style_tag doc << content if content else tmpl_el << style_tag if style_tag tmpl_el.at_css("slot:not([name])")&.swap(content) if content tmpl_el.children.each do |node| doc << node end tmpl_el.remove end # And that is that. return_node ? doc : doc.to_html end def call(...) render_element(...) end # Override in component if need be, otherwise we'll use the node walker/binding pipeline # # @param fragment [Nokogiri::XML::Element] # @return [void] def process_fragment(fragment) Fragment.new( fragment, self.class.attribute_bindings.each { _1.method = method(_1.method_name) }, html_module: self.class.html_module ).process end def inspect "#<#{self.class.name} #{attributes}>" end end