1212namespace Twig \Extra \Html ;
1313
1414use Symfony \Component \Mime \MimeTypes ;
15+ use Twig \Environment ;
1516use Twig \Error \RuntimeError ;
1617use Twig \Extension \AbstractExtension ;
18+ use Twig \Extra \Html \HtmlAttr \AttributeValueInterface ;
19+ use Twig \Extra \Html \HtmlAttr \InlineStyle ;
20+ use Twig \Extra \Html \HtmlAttr \MergeableInterface ;
21+ use Twig \Extra \Html \HtmlAttr \SeparatedTokenList ;
1722use Twig \Markup ;
23+ use Twig \Runtime \EscaperRuntime ;
1824use Twig \TwigFilter ;
1925use Twig \TwigFunction ;
2026
@@ -31,6 +37,8 @@ public function getFilters(): array
3137 {
3238 return [
3339 new TwigFilter ('data_uri ' , [$ this , 'dataUri ' ]),
40+ new TwigFilter ('html_attr_merge ' , [self ::class, 'htmlAttrMerge ' ]),
41+ new TwigFilter ('html_attr_type ' , [self ::class, 'htmlAttrType ' ]),
3442 ];
3543 }
3644
@@ -39,6 +47,7 @@ public function getFunctions(): array
3947 return [
4048 new TwigFunction ('html_classes ' , [self ::class, 'htmlClasses ' ]),
4149 new TwigFunction ('html_cva ' , [self ::class, 'htmlCva ' ]),
50+ new TwigFunction ('html_attr ' , [self ::class, 'htmlAttr ' ], ['needs_environment ' => true , 'is_safe ' => ['html ' ]]),
4251 ];
4352 }
4453
@@ -125,4 +134,129 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar
125134 {
126135 return new Cva ($ base , $ variants , $ compoundVariants , $ defaultVariant );
127136 }
137+
138+ /** @internal */
139+ public static function htmlAttrType (mixed $ value , string $ type = 'sst ' ): AttributeValueInterface
140+ {
141+ return match ($ type ) {
142+ 'sst ' => new SeparatedTokenList ($ value , ' ' ),
143+ 'cst ' => new SeparatedTokenList ($ value , ', ' ),
144+ 'style ' => new InlineStyle ($ value ),
145+ default => throw new RuntimeError (\sprintf ('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style". ' , $ type )),
146+ };
147+ }
148+
149+ /** @internal */
150+ public static function htmlAttrMerge (iterable |string |false |null ...$ arrays ): array
151+ {
152+ $ result = [];
153+
154+ foreach ($ arrays as $ array ) {
155+ if (!$ array ) {
156+ continue ;
157+ }
158+
159+ if (\is_string ($ array )) {
160+ throw new RuntimeError ('Only empty strings may be passed as string arguments to html_attr_merge. This is to support the implicit else clause for ternary operators. ' );
161+ }
162+
163+ foreach ($ array as $ key => $ value ) {
164+ if (!isset ($ result [$ key ])) {
165+ $ result [$ key ] = $ value ;
166+
167+ continue ;
168+ }
169+
170+ $ existing = $ result [$ key ];
171+
172+ switch (true ) {
173+ case $ value instanceof MergeableInterface:
174+ $ result [$ key ] = $ value ->mergeInto ($ existing );
175+ break ;
176+ case $ existing instanceof MergeableInterface:
177+ $ result [$ key ] = $ existing ->appendFrom ($ value );
178+ break ;
179+ case is_iterable ($ existing ) && is_iterable ($ value ):
180+ $ result [$ key ] = [...$ existing , ...$ value ];
181+ break ;
182+ case (\is_scalar ($ existing ) || \is_object ($ existing )) && (\is_scalar ($ value ) || \is_object ($ value )):
183+ $ result [$ key ] = $ value ;
184+ break ;
185+ default :
186+ throw new RuntimeError (\sprintf ('Cannot merge incompatible values for key "%s". ' , $ key ));
187+ }
188+ }
189+ }
190+
191+ return $ result ;
192+ }
193+
194+ /** @internal */
195+ public static function htmlAttr (Environment $ env , iterable |string |false |null ...$ args ): string
196+ {
197+ $ attr = self ::htmlAttrMerge (...$ args );
198+
199+ $ result = '' ;
200+ $ runtime = $ env ->getRuntime (EscaperRuntime::class);
201+
202+ foreach ($ attr as $ name => $ value ) {
203+ if (str_starts_with ($ name , 'aria- ' )) {
204+ // For aria-*, convert booleans to "true" and "false" strings
205+ if (true === $ value ) {
206+ $ value = 'true ' ;
207+ } elseif (false === $ value ) {
208+ $ value = 'false ' ;
209+ }
210+ }
211+
212+ if (str_starts_with ($ name , 'data- ' )) {
213+ if (!$ value instanceof AttributeValueInterface && null !== $ value && !\is_scalar ($ value )) {
214+ // ... encode non-null non-scalars as JSON
215+ try {
216+ $ value = json_encode ($ value , \JSON_THROW_ON_ERROR );
217+ } catch (\JsonException $ e ) {
218+ throw new RuntimeError (\sprintf ('The "%s" attribute value cannot be JSON encoded. ' , $ name ), previous: $ e );
219+ }
220+ } elseif (true === $ value ) {
221+ // ... and convert boolean true to a 'true' string.
222+ $ value = 'true ' ;
223+ }
224+ }
225+
226+ // Convert iterable values to token lists
227+ if (!$ value instanceof AttributeValueInterface && is_iterable ($ value )) {
228+ if ('style ' === $ name ) {
229+ $ value = new InlineStyle ($ value );
230+ } else {
231+ $ value = new SeparatedTokenList ($ value );
232+ }
233+ }
234+
235+ if ($ value instanceof AttributeValueInterface) {
236+ $ value = $ value ->getValue ();
237+ }
238+
239+ // In general, ...
240+ if (true === $ value ) {
241+ // ... use attribute="" for boolean true,
242+ // which is XHTML compliant and indicates the "empty value default", see
243+ // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
244+ // https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
245+ $ value = '' ;
246+ }
247+
248+ if (null === $ value || false === $ value ) {
249+ // omit null-valued and false attributes completely (note aria-* has been processed before)
250+ continue ;
251+ }
252+
253+ if (\is_object ($ value ) && !$ value instanceof \Stringable) {
254+ throw new RuntimeError (\sprintf ('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s". ' , $ name , \Stringable::class, get_debug_type ($ value )));
255+ }
256+
257+ $ result .= $ runtime ->escape ($ name , 'html_attr_relaxed ' ).'=" ' .$ runtime ->escape ((string ) $ value ).'" ' ;
258+ }
259+
260+ return trim ($ result );
261+ }
128262}
0 commit comments