From b5be871181ac9af1e69c29ab6b9c3648b7c3a25c Mon Sep 17 00:00:00 2001 From: SB Software Agent Date: Tue, 10 Feb 2026 22:48:48 +0100 Subject: [PATCH 1/5] HTML-50 Support case statements in templates Add Case AST handling to the template evaluation macro so tag method calls inside case/when branches are evaluated correctly. Includes support for expressionless case forms (case; when ...), plus a spec covering when/else branches and nested case inside a tag block. --- spec/instance_template/case_spec.cr | 83 +++++++++++++++++++++++++++++ src/instance_template.cr | 25 +++++++++ 2 files changed, 108 insertions(+) create mode 100644 spec/instance_template/case_spec.cr diff --git a/spec/instance_template/case_spec.cr b/spec/instance_template/case_spec.cr new file mode 100644 index 0000000..09d2cc3 --- /dev/null +++ b/spec/instance_template/case_spec.cr @@ -0,0 +1,83 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::CaseSpec + enum Kind + A + B + Other + end + + class MyView + getter kind : Kind + + def initialize(@kind) + end + + ToHtml.instance_template do + case kind + when Kind::A + p { "A" } + when Kind::B + div do + span { "B" } + end + else + i { "Other" } + end + + div do + case + when kind == Kind::A + strong { "inner-a" } + else + em { "inner-not-a" } + end + end + end + + describe "MyView#to_html" do + it "renders the correct HTML for Kind::A" do + expected = <<-HTML +

A

+
+ + inner-a + +
+ HTML + + MyView.new(Kind::A).to_html.should eq(expected.squish) + end + + it "renders the correct HTML for Kind::B" do + expected = <<-HTML +
+ + B + +
+
+ + inner-not-a + +
+ HTML + + MyView.new(Kind::B).to_html.should eq(expected.squish) + end + + it "renders the correct HTML for Kind::Other" do + expected = <<-HTML + Other +
+ + inner-not-a + +
+ HTML + + MyView.new(Kind::Other).to_html.should eq(expected.squish) + end + end + end +end diff --git a/src/instance_template.cr b/src/instance_template.cr index c02dcef..6502c4a 100644 --- a/src/instance_template.cr +++ b/src/instance_template.cr @@ -130,6 +130,31 @@ module ToHtml {% end %} {% end %} end + {% elsif blk.body.is_a?(Case) %} + {% if blk.body.cond.is_a?(Nop) %} + case + {% else %} + case {{blk.body.cond}} + {% end %} + {% for w in blk.body.whens %} + when {{w.conds.splat}} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{w.body}} + end + {% if flag?(:to_html_pretty) && break_line %} + {{io}} << "\n" + {% end %} + {% end %} + {% if !blk.body.else.is_a?(Nop) %} + else + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.else}} + end + {% if flag?(:to_html_pretty) && break_line %} + {{io}} << "\n" + {% end %} + {% end %} + end {% elsif blk.body.is_a?(MacroIf) %} \{% if {{blk.body.cond}} %} ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do From 5c69316e4bbdcf5a7216f24cd903f3d048b3e04c Mon Sep 17 00:00:00 2001 From: SB Software Agent Date: Wed, 11 Feb 2026 21:46:36 +0100 Subject: [PATCH 2/5] Support each_with_index and times blocks in template evaluator --- .../instance_template/iterator_blocks_spec.cr | 95 +++++++++++++++++++ src/instance_template.cr | 36 ++++++- 2 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 spec/instance_template/iterator_blocks_spec.cr diff --git a/spec/instance_template/iterator_blocks_spec.cr b/spec/instance_template/iterator_blocks_spec.cr new file mode 100644 index 0000000..303e61b --- /dev/null +++ b/spec/instance_template/iterator_blocks_spec.cr @@ -0,0 +1,95 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::IteratorBlocksSpec + class IndexedItem + getter value : String + + def initialize(@value) + end + + ToHtml.instance_template do + em { value } + end + end + + class IndexedList + getter items : Array(String) + + def initialize(@items) + end + + ToHtml.instance_template do + ul do + items.each_with_index do |item, idx| + li do + if idx.even? + span { idx.to_s } + else + IndexedItem.new(item) + end + + unless idx == items.size - 1 + small { "present" } + end + end + end + end + end + end + + class TimesList + ToHtml.instance_template do + ol do + 3.times do |i| + li do + if i.even? + "even" + else + "odd" + end + end + end + + 2.times do + li { "tail" } + end + end + end + end + + describe "iterator blocks" do + it "supports each_with_index with nested template control flow" do + expected = <<-HTML + + HTML + + IndexedList.new(["one", "two"]).to_html.should eq(expected.squish) + end + + it "supports times with and without block args" do + expected = <<-HTML +
    +
  1. even
  2. +
  3. odd
  4. +
  5. even
  6. +
  7. tail
  8. +
  9. tail
  10. +
+ HTML + + TimesList.new.to_html.should eq(expected.squish) + end + end +end diff --git a/src/instance_template.cr b/src/instance_template.cr index c02dcef..2cc69bb 100644 --- a/src/instance_template.cr +++ b/src/instance_template.cr @@ -83,14 +83,44 @@ module ToHtml ToHtml.to_html_add_tag({{io}}, {{indent_level}}, {{break_line}}, {{blk.body}}) {% elsif blk.body.is_a?(Call) && blk.body.receiver.nil? && blk.body.name.stringify == "doctype" %} {{io}} << "" - {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.name.stringify == "each" %} - {{blk.body.receiver}}.each_with_index do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}, %index| {% end %} + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "each" %} + {% if flag?(:to_html_pretty) %} + %to_html_each_break_line = false + {% end %} + {{blk.body.receiver}}.each do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} + {% if flag?(:to_html_pretty) %} + {{io}} << "\n" if %to_html_each_break_line + %to_html_each_break_line = true + {% end %} ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do {{blk.body.block.body}} end + end + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "each_with_index" %} + {% if flag?(:to_html_pretty) %} + %to_html_each_with_index_break_line = false + {% end %} + {{blk.body.receiver}}.each_with_index do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} {% if flag?(:to_html_pretty) %} - {{io}} << "\n" unless %index == {{blk.body.receiver}}.size - 1 + {{io}} << "\n" if %to_html_each_with_index_break_line + %to_html_each_with_index_break_line = true {% end %} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.block.body}} + end + end + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "times" %} + {% if flag?(:to_html_pretty) %} + %to_html_times_break_line = false + {% end %} + {{blk.body.receiver}}.times do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} + {% if flag?(:to_html_pretty) %} + {{io}} << "\n" if %to_html_times_break_line + %to_html_times_break_line = true + {% end %} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.block.body}} + end end {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.name.stringify == "to_html" && blk.body.block %} {{blk.body.receiver}}.to_html({{io}}, {{indent_level}}) do |%io, %indent_level| From 6ff6ddd362376e194b189e460e7242c5644b0d8a Mon Sep 17 00:00:00 2001 From: SB Software Agent Date: Wed, 11 Feb 2026 22:20:56 +0100 Subject: [PATCH 3/5] HTML-50 Always print case condition Simplifying macro logic --- src/instance_template.cr | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/instance_template.cr b/src/instance_template.cr index 6502c4a..f550d5d 100644 --- a/src/instance_template.cr +++ b/src/instance_template.cr @@ -131,11 +131,7 @@ module ToHtml {% end %} end {% elsif blk.body.is_a?(Case) %} - {% if blk.body.cond.is_a?(Nop) %} - case - {% else %} - case {{blk.body.cond}} - {% end %} + case {{blk.body.cond}} {% for w in blk.body.whens %} when {{w.conds.splat}} ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do From 93843049f424a0b82f9775386dea5e4f80886970 Mon Sep 17 00:00:00 2001 From: sbsoftware-agent Date: Thu, 12 Feb 2026 00:06:49 +0100 Subject: [PATCH 4/5] HTML-51 Add data and aria attribute expansion helpers (#32) * HTML-51 Add data/aria attribute expansion helpers with explicit key precedence * HTML-51 Remove precedence & support nesting * Simplify data/aria attribute normalization flow * Merge Hash and NamedTuple attribute overloads --- README.md | 23 ++++++ .../data_aria_attributes_spec.cr | 74 +++++++++++++++++++ src/attribute_hash.cr | 70 ++++++++++++++++++ src/instance_template.cr | 2 +- 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 spec/instance_template/data_aria_attributes_spec.cr diff --git a/README.md b/README.md index 7cd897a..2ed496c 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,29 @@ end puts NamedArgumentsView.new.to_html ``` +#### `data` / `aria` Helpers + +You can define `data-*` and `aria-*` attributes via `data:` / `aria:` hashes or named tuples. +When multiple sources assign the same `data-*` / `aria-*` key, the last assignment wins. + +```crystal +require "to_html" + +class DataAriaView + ToHtml.instance_template do + div( + {"data-foo", "from-tuple"}, + data_foo: "from-explicit", + data: {foo: "from-hash", user_id: 42, active: true, ignored: nil}, + aria: {label: "Profile", hidden: false} + ) + end +end + +#
+puts DataAriaView.new.to_html +``` + #### Object Interface Another way to add attributes is via objects that implement `#to_html_attrs`. The easiest way to do so is via the `ToHtml.instance_tag_attrs`/`ToHtml.class_tag_attrs` macros. diff --git a/spec/instance_template/data_aria_attributes_spec.cr b/spec/instance_template/data_aria_attributes_spec.cr new file mode 100644 index 0000000..4a2296a --- /dev/null +++ b/spec/instance_template/data_aria_attributes_spec.cr @@ -0,0 +1,74 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::DataAriaAttributesSpec + class BasicView + ToHtml.instance_template do + div data: {foo: "bar"}, aria: {label: "X"} + end + end + + class TypeView + ToHtml.instance_template do + div data: {enabled: true, count: 7, ratio: 2.5, ignored: nil}, aria: {hidden: false, current: nil} + end + end + + class ProviderAttrs + ToHtml.class_tag_attrs do + data = {foo: "provider-hash", bar: 1} + aria = {label: "provider-label"} + data_foo = "provider-explicit" + aria_label = "provider-explicit-label" + end + end + + class MergeView + ToHtml.instance_template do + attrs = [ + ProviderAttrs, + {data: {foo: "array-hash", baz: true}, aria: {label: "array-label", hidden: false}}, + ] + + div attrs, {"data-foo", "tuple-explicit"}, {"aria-label", "tuple-explicit-label"}, + data_foo: "named-explicit", + aria_label: "named-explicit-label", + data: {"foo" => "named-hash-last", "count" => 2.5, "bar" => nil, "data-prefixed" => "normalized"}, + aria: {"label" => "named-label-last", "current" => false, "omitted" => nil} + end + end + + class NormalizeView + ToHtml.instance_template do + div data: {"foo_bar" => "snake", :"foo-baz" => "dash", :"data-qux" => "prefixed"}, + aria: {"labelled_by" => "target", :"aria-described-by" => "description"} + end + end + + class NestedView + ToHtml.instance_template do + div data: {foo: {blah: "baz"}}, aria: {label: {text: "X"}} + end + end + + describe "data/aria attribute expansion" do + it "expands data and aria named tuple attributes" do + BasicView.new.to_html.should eq(%(
)) + end + + it "serializes bool and numbers while omitting nil values" do + TypeView.new.to_html.should eq(%(
)) + end + + it "merges array, tuple, to_html_attrs, and named args with last-write-wins semantics" do + MergeView.new.to_html.should eq(%(
)) + end + + it "normalizes symbol and string keys for data/aria hashes" do + NormalizeView.new.to_html.should eq(%(
)) + end + + it "flattens nested data/aria hashes and named tuples" do + NestedView.new.to_html.should eq(%(
)) + end + end +end diff --git a/src/attribute_hash.cr b/src/attribute_hash.cr index 9a1b288..17a01fd 100644 --- a/src/attribute_hash.cr +++ b/src/attribute_hash.cr @@ -10,12 +10,30 @@ module ToHtml end def []=(key, value : Bool) + key = key.to_s + + if prefixed_key = normalize_explicit_prefixed_key(key) + attributes[prefixed_key] = value.to_s + return + end + return unless value boolean_attributes << key end def []=(key, value) + key = key.to_s + + if prefixed_key = normalize_explicit_prefixed_key(key) + assign_prefixed_value(prefixed_key, value) + return + end + + if key == "data" || key == "aria" + return if assign_prefixed_hash(key, value) + end + if attributes.has_key?(key) attributes[key] += " #{value}" if value else @@ -38,5 +56,57 @@ module ToHtml io << " " if boolean_attributes.any? boolean_attributes.join(io, " ") end + + private def assign_prefixed_hash(prefix : String, value : Hash | NamedTuple) : Bool + prefix_with_separator = "#{prefix}-" + value.each do |raw_key, raw_value| + key = raw_key.to_s.strip.gsub("_", "-") + next if key.empty? || key == prefix + + if key.starts_with?(prefix_with_separator) + next if key.size == prefix_with_separator.size + + assign_prefixed_value(key, raw_value) + else + assign_prefixed_value("#{prefix_with_separator}#{key}", raw_value) + end + end + + true + end + + private def assign_prefixed_hash(_prefix : String, _value) : Bool + false + end + + private def normalize_explicit_prefixed_key(key : String) : String? + key = key.gsub("_", "-") + + if key.starts_with?("data-") + return if key == "data-" + key + elsif key.starts_with?("aria-") + return if key == "aria-" + key + end + end + + # Nested data/aria maps are flattened recursively into hyphen-separated keys. + private def assign_prefixed_value(key : String, value : Hash | NamedTuple) + value.each do |raw_key, raw_value| + nested_key_part = raw_key.to_s.strip.gsub("_", "-") + next if nested_key_part.empty? + + assign_prefixed_value("#{key}-#{nested_key_part}", raw_value) + end + end + + private def assign_prefixed_value(key : String, _value : Nil) + attributes.delete(key) + end + + private def assign_prefixed_value(key : String, value) + attributes[key] = value.to_s + end end end diff --git a/src/instance_template.cr b/src/instance_template.cr index 0bf6082..4f05b9b 100644 --- a/src/instance_template.cr +++ b/src/instance_template.cr @@ -302,7 +302,7 @@ module ToHtml %attr_hash[{{arg}}.first] = {{arg}}.last {% else %} %arg = {{arg}} - if %arg.is_a?(Array) + if %arg.is_a?(Array) || %arg.is_a?(Tuple) %arg.each do |item| item.to_html_attrs({{call.name.stringify}}, %attr_hash) end From e05aca20445b6ceaabf04d97469c28535290c1b2 Mon Sep 17 00:00:00 2001 From: Stefan Bilharz Date: Wed, 25 Feb 2026 21:56:13 +0100 Subject: [PATCH 5/5] Bump version to v1.7.0 --- shard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 6951038..89a3494 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: to_html -version: 1.6.0 +version: 1.7.0 license: MIT authors: - Stefan Bilharz