From 41a58c8e0acc0411e79c0d496d7a9f9280a57d66 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Fri, 14 Feb 2025 14:43:31 +0000 Subject: [PATCH] Tag helper (#859) --- lib/phlex/html.rb | 80 ++++++++++++++++++--- lib/phlex/sgml.rb | 1 + lib/phlex/svg.rb | 39 ++++++++++ quickdraw/tag.test.rb | 161 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 quickdraw/tag.test.rb diff --git a/lib/phlex/html.rb b/lib/phlex/html.rb index e9cc4ba9..d8716f77 100644 --- a/lib/phlex/html.rb +++ b/lib/phlex/html.rb @@ -16,26 +16,88 @@ def doctype nil end - # Outputs an `` tag - # @return [nil] - # @see https://developer.mozilla.org/docs/Web/SVG/Element/svg - def svg(...) + # Outputs an `` tag. + # + # [MDN Docs](https://developer.mozilla.org/docs/Web/SVG/Element/svg) + # [Spec](https://html.spec.whatwg.org/#the-svg-element) + def svg(*, **, &) if block_given? - super do - render Phlex::SVG.new do |svg| - yield(svg) - end - end + super { render Phlex::SVG.new(&) } else super end end + # Override to provide a filename for the HTML file def filename nil end + # Returns the string "text/html" def content_type "text/html" end + + # Output an HTML tag dynamically, e.g: + # + # ```ruby + # @tag_name = :h1 + # tag(@tag_name, class: "title") + # ``` + def tag(name, **attributes, &) + state = @_state + block_given = block_given? + buffer = state.buffer + + unless state.should_render? + yield(self) if block_given + return nil + end + + unless Symbol === name + raise Phlex::ArgumentError.new("Expected the tag name to be a Symbol.") + end + + if (tag = StandardElements.__registered_elements__[name]) || (tag = name.name.tr("_", "-")).include?("-") + if attributes.length > 0 # with attributes + if block_given # with content block + buffer << "<#{tag}" << (Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">" + if tag == "svg" + render Phlex::SVG.new(&) + else + __yield_content__(&) + end + buffer << "" + else # without content + buffer << "<#{tag}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">" + end + else # without attributes + if block_given # with content block + buffer << ("<#{tag}>") + if tag == "svg" + render Phlex::SVG.new(&) + else + __yield_content__(&) + end + buffer << "" + else # without content + buffer << "<#{tag}>" + end + end + elsif (tag = VoidElements.__registered_elements__[name]) + if block_given + raise Phlex::ArgumentError.new("Void elements cannot have content blocks.") + end + + if attributes.length > 0 # with attributes + buffer << "<#{tag}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">" + else # without attributes + buffer << "<#{tag}>" + end + + nil + else + raise Phlex::ArgumentError.new("Invalid HTML tag: #{name}") + end + end end diff --git a/lib/phlex/sgml.rb b/lib/phlex/sgml.rb index cee8d0c8..22a9edab 100644 --- a/lib/phlex/sgml.rb +++ b/lib/phlex/sgml.rb @@ -188,6 +188,7 @@ def safe(value) alias_method :🦺, :safe + # Flush the current state to the output buffer. def flush @_state.flush end diff --git a/lib/phlex/svg.rb b/lib/phlex/svg.rb index eb11b73f..e5536fe3 100644 --- a/lib/phlex/svg.rb +++ b/lib/phlex/svg.rb @@ -5,11 +5,50 @@ class Phlex::SVG < Phlex::SGML include StandardElements + # Returns the string "image/svg+xml" def content_type "image/svg+xml" end + # Override to provide a filename for the SVG file def filename nil end + + def tag(name, **attributes, &) + state = @_state + block_given = block_given? + buffer = state.buffer + + unless state.should_render? + yield(self) if block_given + return nil + end + + unless Symbol === name + raise Phlex::ArgumentError.new("Expected the tag name to be a Symbol.") + end + + if (tag = StandardElements.__registered_elements__[name]) || (tag = name.name.tr("_", "-")).include?("-") + if attributes.length > 0 # with attributes + if block_given # with content block + buffer << "<#{tag}" << (Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">" + __yield_content__(&) + buffer << "" + else # without content + buffer << "<#{tag}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">" + end + else # without attributes + if block_given # with content block + buffer << ("<#{tag}>") + __yield_content__(&) + buffer << "" + else # without content + buffer << "<#{tag}>" + end + end + else + raise Phlex::ArgumentError.new("Invalid SVG tag: #{name}") + end + end end diff --git a/quickdraw/tag.test.rb b/quickdraw/tag.test.rb new file mode 100644 index 00000000..63836f99 --- /dev/null +++ b/quickdraw/tag.test.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +class HTMLComponent < Phlex::HTML + def initialize(tag, **attributes) + @tag = tag + @attributes = attributes + end + + def view_template(&) + tag(@tag, **@attributes, &) + end +end + +class SVGComponent < Phlex::SVG + def initialize(tag, **attributes) + @tag = tag + @attributes = attributes + end + + def view_template(&) + tag(@tag, **@attributes, &) + end +end + +Phlex::HTML::VoidElements.__registered_elements__.each do |method_name, tag| + test "<#{tag}> HTML tag without attributes" do + output = HTMLComponent.call(tag.to_sym) + + assert_equal_html output, <<~HTML.strip + <#{tag}> + HTML + end + + test "<#{tag}> HTML tag with attributes" do + output = HTMLComponent.call(tag.to_sym, class: "class", id: "id", disabled: true) + + assert_equal_html output, <<~HTML.strip + <#{tag} class="class" id="id" disabled> + HTML + end + + test "<#{tag}> HTML tag with content" do + error = assert_raises Phlex::ArgumentError do + HTMLComponent.call(tag.to_sym) do + "Hello, world!" + end + end + + assert_equal error.message, "Void elements cannot have content blocks." + end +end + +Phlex::HTML::StandardElements.__registered_elements__.each do |method_name, tag| + test "<#{tag}> HTML tag without attributes" do + output = HTMLComponent.call(tag.to_sym) + + assert_equal_html output, <<~HTML.strip + <#{tag}> + HTML + end + + test "<#{tag}> HTML tag with attributes" do + output = HTMLComponent.call(tag.to_sym, class: "class", id: "id", disabled: true) + + assert_equal_html output, <<~HTML.strip + <#{tag} class="class" id="id" disabled> + HTML + end + + test "<#{tag}> HTML tag with content" do + output = HTMLComponent.call(tag.to_sym) do + "Hello, world!" + end + + assert_equal_html output, <<~HTML.strip + <#{tag}>Hello, world! + HTML + end + + test "<#{tag}> HTML tag with content and attributes" do + output = HTMLComponent.call(tag.to_sym, class: "class", id: "id", disabled: true) do + "Hello, world!" + end + + assert_equal_html output, <<~HTML.strip + <#{tag} class="class" id="id" disabled>Hello, world! + HTML + end +end + +Phlex::SVG::StandardElements.__registered_elements__.each do |method_name, tag| + test "<#{tag}> SVG tag without attributes" do + output = SVGComponent.call(tag.to_sym) + + assert_equal_html output, <<~HTML.strip + <#{tag}> + HTML + end + + test "<#{tag}> SVG tag with attributes" do + output = SVGComponent.call(tag.to_sym, class: "class", id: "id", disabled: true) + + assert_equal_html output, <<~HTML.strip + <#{tag} class="class" id="id" disabled> + HTML + end + + test "<#{tag}> SVG tag with content" do + output = SVGComponent.call(tag.to_sym) do + "Hello, world!" + end + + assert_equal_html output, <<~HTML.strip + <#{tag}>Hello, world! + HTML + end + + test "<#{tag}> SVG tag with content and attributes" do + output = SVGComponent.call(tag.to_sym, class: "class", id: "id", disabled: true) do + "Hello, world!" + end + + assert_equal_html output, <<~HTML.strip + <#{tag} class="class" id="id" disabled>Hello, world! + HTML + end +end + +test "svg tag in HTML" do + output = HTMLComponent.call(:svg) do |svg| + svg.circle(cx: 50, cy: 50, r: 40, fill: "red") + end + + assert_equal_html output, <<~HTML.strip + + HTML +end + +test "with invalid tag name" do + error = assert_raises Phlex::ArgumentError do + HTMLComponent.call(:invalidtag) + end + + assert_equal error.message, "Invalid HTML tag: invalidtag" +end + +test "with invalid tag name input type" do + error = assert_raises Phlex::ArgumentError do + HTMLComponent.call("div") + end + + assert_equal error.message, "Expected the tag name to be a Symbol." +end + +test "with custom tag name" do + output = HTMLComponent.call(:custom_tag) + + assert_equal_html output, <<~HTML.strip + + HTML +end