diff --git a/src/Illuminate/Collections/Arr.php b/src/Illuminate/Collections/Arr.php index cddcde2ecc9f..43e36422fcc0 100644 --- a/src/Illuminate/Collections/Arr.php +++ b/src/Illuminate/Collections/Arr.php @@ -4,7 +4,9 @@ use ArgumentCountError; use ArrayAccess; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Traits\Macroable; +use Illuminate\View\ComponentAttributeBag; use InvalidArgumentException; use Random\Randomizer; @@ -891,6 +893,29 @@ public static function toCssClasses($array) return implode(' ', $classes); } + /** + * Conditionally compile attributes from an array into an HTML attribute string. + * + * @param array $array + * @return string + */ + public static function toHtmlAttributes($array) + { + return Collection::make($array)->map(function ($value, $key) { + if ($key) { + $value = value($value); + return is_null($value) || $value === false ? null : sprintf('%s="%s"', $key, $value); + } elseif ($value instanceof ComponentAttributeBag) { + return self::toHtmlAttributes($value->getAttributes()); + } else { + return sprintf("%s", $value); + } + }) + ->filter() + ->values() + ->implode(" "); + } + /** * Conditionally compile styles from an array into a style list. * diff --git a/src/Illuminate/View/Compilers/BladeCompiler.php b/src/Illuminate/View/Compilers/BladeCompiler.php index 7911462ebf32..a26431a2cfb8 100644 --- a/src/Illuminate/View/Compilers/BladeCompiler.php +++ b/src/Illuminate/View/Compilers/BladeCompiler.php @@ -15,6 +15,7 @@ class BladeCompiler extends Compiler implements CompilerInterface { use Concerns\CompilesAuthorizations, + Concerns\CompilesAttributes, Concerns\CompilesClasses, Concerns\CompilesComments, Concerns\CompilesComponents, diff --git a/src/Illuminate/View/Compilers/ComponentTagCompiler.php b/src/Illuminate/View/Compilers/ComponentTagCompiler.php index 9c3d6adbec99..fa0c85ec917a 100644 --- a/src/Illuminate/View/Compilers/ComponentTagCompiler.php +++ b/src/Illuminate/View/Compilers/ComponentTagCompiler.php @@ -19,6 +19,9 @@ */ class ComponentTagCompiler { + + use Concerns\CompilesAttributes; + /** * The Blade compiler instance. * @@ -111,6 +114,10 @@ protected function compileOpeningTags(string $value) (?: \s+ (?: + (?: + @(?:attributes)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | (?: @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) ) @@ -150,9 +157,7 @@ protected function compileOpeningTags(string $value) return preg_replace_callback($pattern, function (array $matches) { $this->boundAttributes = []; - $attributes = $this->getAttributesFromAttributeString($matches['attributes']); - return $this->componentString($matches[1], $attributes); }, $value); } @@ -176,6 +181,10 @@ protected function compileSelfClosingTags(string $value) (?: \s+ (?: + (?: + @(?:attributes)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | (?: @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) ) @@ -511,6 +520,10 @@ public function compileSlots(string $value) (?: \s+ (?: + (?: + @(?:attributes)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | (?: @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) ) @@ -545,6 +558,8 @@ public function compileSlots(string $value) /x"; $value = preg_replace_callback($pattern, function ($matches) { + + $name = $this->stripQuotes($matches['inlineName'] ?: $matches['name'] ?: $matches['boundName']); if (Str::contains($name, '-') && ! empty($matches['inlineName'])) { @@ -558,6 +573,7 @@ public function compileSlots(string $value) $this->boundAttributes = []; + $attributes = $this->getAttributesFromAttributeString($matches['attributes']); // If an inline name was provided and a name or bound name was *also* provided, we will assume the name should be an attribute... @@ -581,8 +597,9 @@ public function compileSlots(string $value) */ protected function getAttributesFromAttributeString(string $attributeString) { - $attributeString = $this->parseShortAttributeSyntax($attributeString); + $attributeString = $this->parseComponentTagAttributesStatements($attributeString); $attributeString = $this->parseAttributeBag($attributeString); + $attributeString = $this->parseShortAttributeSyntax($attributeString); $attributeString = $this->parseComponentTagClassStatements($attributeString); $attributeString = $this->parseComponentTagStyleStatements($attributeString); $attributeString = $this->parseBindAttributes($attributeString); @@ -607,7 +624,8 @@ protected function getAttributesFromAttributeString(string $attributeString) return []; } - return collect($matches)->mapWithKeys(function ($match) { + return collect($matches) + ->mapWithKeys(function ($match) { $attribute = $match['attribute']; $value = $match['value'] ?? null; @@ -632,7 +650,8 @@ protected function getAttributesFromAttributeString(string $attributeString) } return [$attribute => $value]; - })->toArray(); + }) + ->toArray(); } /** @@ -678,7 +697,6 @@ protected function parseComponentTagClassStatements(string $attributeString) '/@(class)(\( ( (?>[^()]+) | (?2) )* \))/x', function ($match) { if ($match[1] === 'class') { $match[2] = str_replace('"', "'", $match[2]); - return ":class=\"\Illuminate\Support\Arr::toCssClasses{$match[2]}\""; } @@ -687,6 +705,60 @@ protected function parseComponentTagClassStatements(string $attributeString) ); } + /** + * Parse @attributes statements in a given attribute string into their fully-qualified syntax. + * + * @param string $attributeString + * @return string + */ + protected function parseComponentTagAttributesStatements(string $attributeString) + { + return preg_replace_callback( + '/@(attributes)(\( ( (?>[^()]+) | (?2) )* \))/x', function ($match) { + + + if ($match[1] === 'attributes') { + return \Illuminate\Support\Arr::toHtmlAttributes( + $this->reformatAttributeExpressionStringToArray($match[2]) + ); + } + + return $match[0]; + }, $attributeString + ); + } + + /** + * Take an attribute string in expression format (surrounded by ([])) + * and reformat it into a compiled HTML attribute string. + * + * @param string $expression + * @return array + */ + public function reformatAttributeExpressionStringToArray(string $expression) + { + preg_match_all( + '/[\'"](?[^\'"]+)[\'"]\s*=>\s*(?:(?true|false)|(?\d+)|[\'"](?[^\'"]*)[\'"])/x', + trim($expression, '()[]'), + $matches, + PREG_SET_ORDER + ); + return \Illuminate\Support\Collection::make($matches) + ->mapWithKeys(function ($match) { + $key = $match['key']; + if (isset($match['bool']) && $match['bool'] !== '') { + $value = $match['bool'] === 'true'; + } elseif (isset($match['int']) && $match['int'] !== '') { + $value = intval($match['int']); + } elseif (isset($match['string'])) { + $value = $match['string']; + } else { + $value = null; + } + return [$key => $value]; + })->toArray(); + } + /** * Parse @style statements in a given attribute string into their fully-qualified syntax. * diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesAttributes.php b/src/Illuminate/View/Compilers/Concerns/CompilesAttributes.php new file mode 100644 index 000000000000..513fc8b0abf1 --- /dev/null +++ b/src/Illuminate/View/Compilers/Concerns/CompilesAttributes.php @@ -0,0 +1,18 @@ +"; + } +} diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index 82044805b7cb..379b7a1cd5a9 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\View\ComponentAttributeBag; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use stdClass; @@ -1235,6 +1236,39 @@ public function testToCssClasses() $this->assertSame('font-bold mt-4 ml-2', $classes); } + public function testToHtmlAttributes() + { + $resultString = Arr::toHtmlAttributes([ + new ComponentAttributeBag([ + 'taylor' => false, + 'joe' => '', + 'aaron' => 0, + 'james' => null, + 'tim' => 'macdonald', + 'christoph' => fn () => "rumpel", + ]), + 'audrey' => '', + 'alex' => 0, + 'leyton' => false, + 'till' => null, + 'josie' => 'cold', + 'hedwood' => fn () => 'melanie' + ]); + + $this->assertStringContainsString( 'audrey=""', $resultString); + $this->assertStringContainsString( 'hedwood="melanie"', $resultString); + $this->assertStringContainsString( 'alex="0"', $resultString); + $this->assertStringNotContainsString( 'leyton', $resultString); + $this->assertStringNotContainsString( 'till', $resultString); + + $this->assertStringContainsString( 'joe=""', $resultString); + $this->assertStringContainsString( 'christoph="rumpel"', $resultString); + $this->assertStringContainsString( 'aaron="0"', $resultString); + $this->assertStringContainsString( 'tim="macdonald"', $resultString); + $this->assertStringNotContainsString( 'taylor', $resultString); + $this->assertStringNotContainsString( 'james', $resultString); + } + public function testToCssStyles() { $styles = Arr::toCssStyles([ diff --git a/tests/View/Blade/BladeAttributesTest.php b/tests/View/Blade/BladeAttributesTest.php new file mode 100644 index 000000000000..d2c2895c0cf9 --- /dev/null +++ b/tests/View/Blade/BladeAttributesTest.php @@ -0,0 +1,14 @@ + \"mt-1\", 'disabled' => false, 'role' => null, 'wire:poll' => fn () => true])>"; + $expected = " \"mt-1\", 'disabled' => false, 'role' => null, 'wire:poll' => fn () => true]); ?>>"; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } +} diff --git a/tests/View/Blade/BladeComponentTagCompilerTest.php b/tests/View/Blade/BladeComponentTagCompilerTest.php index 38a4a037b21a..4244d918fb63 100644 --- a/tests/View/Blade/BladeComponentTagCompilerTest.php +++ b/tests/View/Blade/BladeComponentTagCompilerTest.php @@ -87,6 +87,26 @@ public function testSlotsWithClassDirectiveCanBeCompiled() $this->assertSame("@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssClasses(\$classes))]) \n".' @endslot', trim($result)); } + public function testSlotsWithAttributesDirectiveCanBeCompiled() + { + $this->mockViewFactory(); + $result = $this->compiler()->compileSlots(' "ml-4", "disabled" => false, "readonly" => 0])>"])> +'); +dd(trim($result)); + $this->assertSame("@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssClasses(\$classes))]) \n".' @endslot', trim($result)); + } + + public function testReformatAttributeExpressionStringToArrayMethod() + { + $compiler = app(ComponentTagCompiler::class); + $result = $compiler->reformatAttributeExpressionStringToArray('(["contains(" => ")bracket", "[other" => "]bracket,", "string" => "string", "bool" => true, "falseBool" => false, "int" => 32, \'singlestring\' => \'single\'])'); + $this->assertEquals('string', $result['string']); + $this->assertEquals('single', $result['singlestring']); + $this->assertTrue($result['bool']); + $this->assertFalse($result['falseBool']); + $this->assertEquals(32, $result['int']); + } + public function testSlotsWithStyleDirectiveCanBeCompiled() { $this->mockViewFactory();