From 03a5de14bced824e7ffbb1df1bba3bcec370c32e Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 6 Dec 2023 23:11:49 +0100 Subject: [PATCH 1/8] Add an `attr` function to make outputting HTML attributes easier --- extra/html-extra/HtmlExtension.php | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index d8a6c0036d1..08395096df7 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -12,8 +12,12 @@ namespace Twig\Extra\Html; use Symfony\Component\Mime\MimeTypes; +use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\Extension\CoreExtension; +use Twig\Extension\EscaperExtension; +use Twig\Runtime\EscaperRuntime; use Twig\TwigFilter; use Twig\TwigFunction; @@ -30,6 +34,7 @@ public function getFilters(): array { return [ new TwigFilter('data_uri', [$this, 'dataUri']), + new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']), ]; } @@ -38,6 +43,7 @@ public function getFunctions(): array return [ new TwigFunction('html_classes', [self::class, 'htmlClasses']), new TwigFunction('html_cva', [self::class, 'htmlCva']), + new TwigFunction('html_attr', [self::class, 'htmlAttr'], ['needs_environment' => true, 'is_safe' => ['html']]), ]; } @@ -124,4 +130,77 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar { return new Cva($base, $variants, $compoundVariants, $defaultVariant); } + + public static function htmlAttrMerge(...$arrays): array + { + $result = []; + + foreach ($arrays as $argNumber => $array) { + if (!$array) { + continue; + } + + if (!is_iterable($array)) { + throw new RuntimeError(sprintf('The "attr_merge" filter only works with arrays or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); + } + + $array = CoreExtension::toArray($array); + + foreach (['class', 'style', 'data', 'aria'] as $deepMergeKey) { + if (isset($array[$deepMergeKey])) { + $value = $array[$deepMergeKey]; + unset($array[$deepMergeKey]); + + if (!is_iterable($value)) { + $value = (array) $value; + } + + $value = CoreExtension::toArray($value); + + $result[$deepMergeKey] = array_merge($result[$deepMergeKey] ?? [], $value); + } + } + + $result = array_merge($result, $array); + } + + return $result; + } + + public static function htmlAttr(Environment $env, ...$args): string + { + $attr = self::htmlAttrMerge(...$args); + + if (isset($attr['class'])) { + $attr['class'] = trim(implode(' ', $attr['class'])); + } + + if (isset($attr['style'])) { + $style = ''; + foreach ($attr['style'] as $name => $value) { + if (is_numeric($name)) { + $style .= $value.'; '; + } else { + $style .= $name.': '.$value.'; '; + } + } + $attr['style'] = trim($style); + } + + if (isset($attr['data'])) { + foreach ($attr['data'] as $name => $value) { + $attr['data-'.$name] = $value; + } + unset($attr['data']); + } + + $result = ''; + $runtime = $env->getRuntime(EscaperRuntime::class); + + foreach ($attr as $name => $value) { + $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" '; + } + + return trim($result); + } } From e3a74bd85d18e42a40f255f880830c4f274e37c2 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 30 Sep 2024 23:21:22 +0200 Subject: [PATCH 2/8] Support boolean attribute values --- extra/html-extra/HtmlExtension.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 08395096df7..94efbe064f9 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -198,6 +198,14 @@ public static function htmlAttr(Environment $env, ...$args): string $runtime = $env->getRuntime(EscaperRuntime::class); foreach ($attr as $name => $value) { + if ($value === false) { + continue; + } + + if ($value === true) { + $value = $name; + } + $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" '; } From aef2551902c1923acc0194094843b3134a6e8c66 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Tue, 1 Oct 2024 10:49:45 +0200 Subject: [PATCH 3/8] Add a first test POC --- extra/html-extra/Tests/HtmlAttrMergeTest.php | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 extra/html-extra/Tests/HtmlAttrMergeTest.php diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php new file mode 100644 index 00000000000..e39c4b87b53 --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php @@ -0,0 +1,30 @@ + [ + ['id' => 'some-id', 'class' => ['some-class']], + [ + ['id' => 'some-id'], + ['class' => 'some-class'], + ] + ]; + } +} From 9722108edb0541bed61808b19a6d7192b6e6e3a3 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 7 Oct 2024 19:19:14 +0200 Subject: [PATCH 4/8] Add tests for key merging --- extra/html-extra/HtmlExtension.php | 33 ++-- extra/html-extra/Tests/HtmlAttrMergeTest.php | 160 ++++++++++++++++++- 2 files changed, 178 insertions(+), 15 deletions(-) diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 94efbe064f9..944f0c7ea50 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -15,8 +15,6 @@ use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; -use Twig\Extension\CoreExtension; -use Twig\Extension\EscaperExtension; use Twig\Runtime\EscaperRuntime; use Twig\TwigFilter; use Twig\TwigFunction; @@ -144,21 +142,32 @@ public static function htmlAttrMerge(...$arrays): array throw new RuntimeError(sprintf('The "attr_merge" filter only works with arrays or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); } - $array = CoreExtension::toArray($array); + $array = (array) ($array); - foreach (['class', 'style', 'data', 'aria'] as $deepMergeKey) { - if (isset($array[$deepMergeKey])) { - $value = $array[$deepMergeKey]; - unset($array[$deepMergeKey]); + foreach (['data', 'aria'] as $flatOutKey) { + if (!isset($array[$flatOutKey])) { + continue; + } + $values = (array) $array[$flatOutKey]; + foreach ($values as $key => $value) { + $result[$flatOutKey.'-'.$key] = $value; + } + unset($array[$flatOutKey]); + } - if (!is_iterable($value)) { - $value = (array) $value; - } + foreach (['class', 'style'] as $deepMergeKey) { + if (!isset($array[$deepMergeKey])) { + continue; + } - $value = CoreExtension::toArray($value); + $value = $array[$deepMergeKey]; + unset($array[$deepMergeKey]); - $result[$deepMergeKey] = array_merge($result[$deepMergeKey] ?? [], $value); + if (!is_iterable($value)) { + $value = (array) $value; } + + $result[$deepMergeKey] = array_merge($result[$deepMergeKey] ?? [], $value); } $result = array_merge($result, $array); diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php index e39c4b87b53..ebbacd6dc28 100644 --- a/extra/html-extra/Tests/HtmlAttrMergeTest.php +++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php @@ -19,11 +19,165 @@ public function testMerge(array $expected, array $inputs): void public function htmlAttrProvider(): \Generator { - yield 'simple test' => [ - ['id' => 'some-id', 'class' => ['some-class']], + yield 'merging different attributes from two arrays' => [ + ['id' => 'some-id', 'label' => 'some-label'], [ ['id' => 'some-id'], - ['class' => 'some-class'], + ['label' => 'some-label'], + ] + ]; + + yield 'merging different attributes from three arrays' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + ['id' => 'some-id'], + ['label' => 'some-label'], + ['role' => 'main'], + ] + ]; + + yield 'merging different attributes from Traversables' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + new \ArrayIterator(['id' => 'some-id']), + new \ArrayIterator(['label' => 'some-label']), + new \ArrayIterator(['role' => 'main']), + ] + ]; + + yield 'later keys override previous ones' => [ + ['id' => 'other'], + [ + ['id' => 'this'], + ['id' => 'that'], + ['id' => 'other'], + ] + ]; + + yield 'ignore empty strings or arrays passed as arguments' => [ + ['some' => 'attribute'], + [ + ['some' => 'attribute'], + [], // empty array + '', // empty string + ] + ]; + + yield 'keep "true" and "false" boolean values' => [ + ['disabled' => true, 'enabled' => false], + [ + ['disabled' => true], + ['enabled' => false], + ] + ]; + + yield 'consolidate values for the "class" key' => [ + ['class' => ['foo', 'bar', 'baz']], + [ + ['class' => ['foo']], + ['class' => 'bar'], // string, not array + ['class' => ['baz']], + ] + ]; + + yield 'class values can be overridden when they use names (array keys)' => [ + ['class' => ['foo', 'bar', 'importance' => 'high']], + [ + ['class' => 'foo'], + ['class' => ['bar', 'importance' => 'low']], + ['class' => ['importance' => 'high']], + ] + ]; + + yield 'inline style values with numerical keys are merely collected' => [ + ['style' => ['font-weight: light', 'color: green', 'font-weight: bold']], + [ + ['style' => ['font-weight: light']], + ['style' => ['color: green', 'font-weight: bold']], + ] + ]; + + yield 'inline style values can be overridden when they use names (array keys)' => [ + ['style' => ['font-weight' => 'bold', 'color' => 'red']], + [ + ['style' => ['font-weight' => 'light']], + ['style' => ['color' => 'green', 'font-weight' => 'bold']], + ['style' => ['color' => 'red']], + ] + ]; + + yield 'no merging happens when mixing numerically indexed inline styles with named ones' => [ + ['style' => ['color: green', 'color' => 'red']], + [ + ['style' => ['color: green']], + ['style' => ['color' => 'red']], + ] + ]; + + yield 'turning aria attributes from array to flat keys' => [ + ['aria-role' => 'banner'], + [ + ['aria' => ['role' => 'main']], + ['aria' => ['role' => 'banner']], + ] + ]; + + yield 'using aria attributes from a sub-array' => [ + ['aria-role' => 'main', 'aria-label' => 'none'], + [ + ['aria' => ['role' => 'main', 'label' => 'none']], + ] + ]; + + yield 'merging aria attributes, where the array values overrides the flat one' => [ + ['aria-role' => 'navigation'], + [ + ['aria-role' => 'main'], + ['aria' => ['role' => 'banner']], + ['aria' => ['role' => 'navigation']], + ] + ]; + + yield 'merging aria attributes, where the flat ones overrides the array' => [ + ['aria-role' => 'navigation'], + [ + ['aria' => ['role' => 'main']], + ['aria-role' => 'banner'], + ['aria-role' => 'navigation'], + ] + ]; + + yield 'using data attributes in a sub-array' => [ + ['data-foo' => 'this', 'data-bar' => 'that'], + [ + ['data' => ['foo' => 'this']], + ['data' => ['bar' => 'that']], + ] + ]; + + yield 'turning data attributes from array to flat keys' => [ + ['data-test' => 'bar'], + [ + ['data' => ['test' => 'foo']], + ['data' => ['test' => 'bar']], + ] + ]; + + yield 'merging data attributes, where the array values overrides the flat one' => [ + ['data-test' => 'baz'], + [ + ['data-test' => 'foo'], + ['data' => ['test' => 'bar']], + ['data' => ['test' => 'baz']], + ] + ]; + + yield 'merging data attributes, where the flat ones overrides the array' => [ + ['data-test' => 'baz'], + [ + ['data' => ['test' => 'foo']], + ['data-test' => 'bar'], + ['data-test' => 'baz'], ] ]; } From 717f872dbb6eb32d19b50009d98fa3aad94fddd3 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 7 Oct 2024 19:26:34 +0200 Subject: [PATCH 5/8] Use `[... ]` instead of `array_merge`# --- extra/html-extra/HtmlExtension.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 944f0c7ea50..9cf7060c486 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -167,10 +167,10 @@ public static function htmlAttrMerge(...$arrays): array $value = (array) $value; } - $result[$deepMergeKey] = array_merge($result[$deepMergeKey] ?? [], $value); + $result[$deepMergeKey] = [...$result[$deepMergeKey] ?? [], ...$value]; } - $result = array_merge($result, $array); + $result = [...$result, ...$array]; } return $result; From 09ff3b4409b472e25b0040a34dba6040da90f503 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 7 Oct 2024 19:27:33 +0200 Subject: [PATCH 6/8] Update extra/html-extra/HtmlExtension.php Co-authored-by: Fabien Potencier --- extra/html-extra/HtmlExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 9cf7060c486..b01cc49e56a 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -139,7 +139,7 @@ public static function htmlAttrMerge(...$arrays): array } if (!is_iterable($array)) { - throw new RuntimeError(sprintf('The "attr_merge" filter only works with arrays or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); + throw new RuntimeError(sprintf('The "attr_merge" filter only works with mappings or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); } $array = (array) ($array); From 799fb287317f3e8bc49b42965321df2ca903029c Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 7 Oct 2024 19:35:45 +0200 Subject: [PATCH 7/8] Fix CS --- extra/html-extra/Tests/HtmlAttrMergeTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php index ebbacd6dc28..28839c448f5 100644 --- a/extra/html-extra/Tests/HtmlAttrMergeTest.php +++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php @@ -10,7 +10,7 @@ class HtmlAttrMergeTest extends TestCase /** * @dataProvider htmlAttrProvider */ - public function testMerge(array $expected, array $inputs): void + public function testMerge(array $expected, array $inputs) { $result = HtmlExtension::htmlAttrMerge(...$inputs); From 0f694fd486a8fe3dbed4d21de179f59f654056a6 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 7 Oct 2024 19:56:32 +0200 Subject: [PATCH 8/8] Add initial functional test for the `html_attr_merge` filter --- .../Tests/Fixtures/html_attr_merge.test | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 extra/html-extra/Tests/Fixtures/html_attr_merge.test diff --git a/extra/html-extra/Tests/Fixtures/html_attr_merge.test b/extra/html-extra/Tests/Fixtures/html_attr_merge.test new file mode 100644 index 00000000000..108f216ac6b --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_attr_merge.test @@ -0,0 +1,19 @@ +--TEST-- +"html_attr_merge" function +--TEMPLATE-- +{% autoescape false %} +#1 - {{ { foo: 'bar' } | html_attr_merge({ bar: 'baz' }) | json_encode }} +#2 - {{ { class: 'foo' } | html_attr_merge({ class: 'bar' }) | json_encode }} +#3 - {{ { class: 'foo' } | html_attr_merge({ class: ['bar', 'baz'] }) | json_encode }} +#4 - {{ { class: { special: 'foo' } } + | html_attr_merge({ class: ['bar', 'baz'] }) + | html_attr_merge({ class: { special: 'qux' } }) + | json_encode }} +{% endautoescape %} +--DATA-- +return [] +--EXPECT-- +#1 - {"foo":"bar","bar":"baz"} +#2 - {"class":["foo","bar"]} +#3 - {"class":["foo","bar","baz"]} +#4 - {"class":{"special":"qux","0":"bar","1":"baz"}}