├── CHANGELOG.md ├── src ├── SlotBag.php ├── Setup.php ├── ComponentLexer.php ├── SlotNode.php ├── ComponentExtension.php ├── ComponentUtilities.php ├── SlotTokenParser.php ├── ComponentTokenParser.php ├── ComponentNode.php ├── AttributesBag.php └── ComponentTagCompiler.php ├── psalm.xml.dist ├── LICENSE.md ├── .php_cs.dist.php ├── composer.json ├── .php-cs-fixer.cache └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `twig-components` will be documented in this file 4 | 5 | ## 0.1.0 - 2020-10-09 6 | 7 | - new syntax to support folders/namespace (thanks @sjelfull) 8 | 9 | ## 0.0.1 - 2020-10-09 10 | 11 | - initial release 12 | -------------------------------------------------------------------------------- /src/SlotBag.php: -------------------------------------------------------------------------------- 1 | slot = $slot; 12 | } 13 | 14 | public function __toString() 15 | { 16 | return $this->slot; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Setup.php: -------------------------------------------------------------------------------- 1 | addExtension(new ComponentExtension($relativePath)); 12 | 13 | $twig->setLexer(new ComponentLexer($twig)); 14 | 15 | /** @var \Twig\Extension\EscaperExtension */ 16 | $escaper = $twig->getExtension(\Twig\Extension\EscaperExtension::class); 17 | $escaper->addSafeClass(AttributesBag::class, ['all']); 18 | $escaper->addSafeClass(SlotBag::class, ['all']); 19 | 20 | return $twig; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ComponentLexer.php: -------------------------------------------------------------------------------- 1 | preparse($source->getCode()); 14 | 15 | return parent::tokenize( 16 | new Source( 17 | $preparsed, 18 | $source->getName(), 19 | $source->getPath() 20 | ) 21 | ); 22 | } 23 | 24 | protected function preparse(string $value): string 25 | { 26 | return (new ComponentTagCompiler($value))->compile(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/SlotNode.php: -------------------------------------------------------------------------------- 1 | $body], ['name' => $name], $lineno, null); 14 | } 15 | 16 | public function compile(Compiler $compiler): void 17 | { 18 | $name = $this->getAttribute('name'); 19 | 20 | $compiler 21 | ->write('ob_start();' . PHP_EOL) 22 | ->subcompile($this->getNode('body')) 23 | ->write('$body = ob_get_clean();' . PHP_EOL) 24 | ->write("\$slots['$name'] = new " . SlotBag::class . "(\$body);" . PHP_EOL . PHP_EOL); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ComponentExtension.php: -------------------------------------------------------------------------------- 1 | relativePath = rtrim($relativePath, DIRECTORY_SEPARATOR); 18 | } 19 | 20 | public function getTokenParsers() 21 | { 22 | return [ 23 | new ComponentTokenParser($this->relativePath), 24 | new SlotTokenParser(), 25 | ]; 26 | } 27 | 28 | public function getFunctions() 29 | { 30 | return [ 31 | new TwigFunction('inherited', [ComponentUtilities::class, 'getInherited'], ['needs_context' => true]) 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ComponentUtilities.php: -------------------------------------------------------------------------------- 1 | $value) { 25 | $camelCaseKey = lcfirst(implode('', array_map('ucwords', explode('-', $key)))); 26 | $camelCase[$key] = $value; 27 | $camelCase[$camelCaseKey] = $value; 28 | } 29 | return $camelCase; 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Giorgio Pogliani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true); 11 | 12 | return (new PhpCsFixer\Config()) 13 | ->setRules([ 14 | '@PSR2' => true, 15 | 'array_syntax' => ['syntax' => 'short'], 16 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 17 | 'no_unused_imports' => true, 18 | 'not_operator_with_successor_space' => true, 19 | 'trailing_comma_in_multiline' => true, 20 | 'phpdoc_scalar' => true, 21 | 'unary_operator_spaces' => true, 22 | 'binary_operator_spaces' => true, 23 | 'blank_line_before_statement' => [ 24 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 25 | ], 26 | 'phpdoc_single_line_var_spacing' => true, 27 | 'phpdoc_var_without_name' => true, 28 | 'class_attributes_separation' => [ 29 | 'elements' => [ 30 | 'method' => 'one', 31 | ], 32 | ], 33 | 'method_argument_space' => [ 34 | 'on_multiline' => 'ensure_fully_multiline', 35 | 'keep_multiple_spaces_after_comma' => true, 36 | ], 37 | 'single_trait_insert_per_statement' => true, 38 | ]) 39 | ->setFinder($finder); -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "performing/twig-components", 3 | "description": "Twig components extension", 4 | "keywords": [ 5 | "digital", 6 | "twig-components" 7 | ], 8 | "homepage": "https://github.com/giorgiopogliani/twig-components", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Giorgio Pogliani", 13 | "email": "giorgiopogliani94@gmail.com", 14 | "homepage": "https://performingdigital.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.2|^8.0", 20 | "twig/twig": "^2.12.5 || ^3.0.0" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.0", 24 | "pestphp/pest": "^1.0", 25 | "phpunit/phpunit": "^9.3", 26 | "symfony/var-dumper": "^5.2", 27 | "vimeo/psalm": "^3.11" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Performing\\TwigComponents\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Performing\\TwigComponents\\Tests\\": "tests" 37 | } 38 | }, 39 | "scripts": { 40 | "psalm": "vendor/bin/psalm", 41 | "test": "vendor/bin/pest", 42 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 43 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "minimum-stability": "dev", 49 | "prefer-stable": true 50 | } 51 | -------------------------------------------------------------------------------- /src/SlotTokenParser.php: -------------------------------------------------------------------------------- 1 | parseArguments(); 15 | 16 | $slot = $this->parser->subparse([$this, 'decideBlockEnd'], true); 17 | 18 | $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); 19 | 20 | return new SlotNode($name, $slot, $token->getLine()); 21 | } 22 | 23 | protected function parseArguments() 24 | { 25 | $stream = $this->parser->getStream(); 26 | 27 | $name = null; 28 | 29 | if ($stream->nextIf(Token::PUNCTUATION_TYPE, ':')) { 30 | $name = $this->parseSlotName(); 31 | } 32 | 33 | $stream->expect(/* Token::BLOCK_END_TYPE */3); 34 | 35 | return $name; 36 | } 37 | 38 | public function parseSlotName(): string 39 | { 40 | $stream = $this->parser->getStream(); 41 | 42 | if ($this->parser->getCurrentToken()->getType() != /** Token::NAME_TYPE */ 5) { 43 | throw new Exception('First token must be a name type'); 44 | } 45 | 46 | return $stream->next()->getValue(); 47 | } 48 | 49 | public function decideBlockEnd(Token $token): bool 50 | { 51 | return $token->test('endslot'); 52 | } 53 | 54 | public function getTag(): string 55 | { 56 | return 'slot'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.0.5","version":"3.0.0","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces":true,"class_definition":true,"constant_case":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_spaces_after_function_name":true,"no_spaces_inside_parenthesis":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"ordered_imports":{"sort_algorithm":"alpha"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"unary_operator_spaces":true,"binary_operator_spaces":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one"}},"single_trait_insert_per_statement":true},"hashes":{"src\/ComponentNode.php":3014195201,"src\/SlotTokenParser.php":1705137408,"src\/Setup.php":2590804620,"src\/SlotBag.php":2813959692,"src\/ComponentTagCompiler.php":1655642996,"src\/ComponentTokenParser.php":1300987293,"src\/ComponentExtension.php":1906178892,"src\/SlotNode.php":3498435644,"src\/ComponentLexer.php":1680794890,"src\/AttributesBag.php":2838536753,"tests\/ComponentTest.php":1567952163}} -------------------------------------------------------------------------------- /src/ComponentTokenParser.php: -------------------------------------------------------------------------------- 1 | path = $path; 25 | } 26 | 27 | public function getComponentPath(string $name) 28 | { 29 | return rtrim($this->path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name . '.twig'; 30 | } 31 | 32 | public function parse(Token $token): Node 33 | { 34 | list($variables, $name) = $this->parseArguments(); 35 | 36 | $slot = $this->parser->subparse([$this, 'decideBlockEnd'], true); 37 | 38 | $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); 39 | 40 | return new ComponentNode($this->getComponentPath($name), $slot, $variables, $token->getLine()); 41 | } 42 | 43 | protected function parseArguments() 44 | { 45 | $stream = $this->parser->getStream(); 46 | 47 | $name = null; 48 | $variables = null; 49 | 50 | if ($stream->nextIf(Token::PUNCTUATION_TYPE, ':')) { 51 | $name = $this->parseComponentName(); 52 | } 53 | 54 | if ($stream->nextIf(/* Token::NAME_TYPE */5, 'with')) { 55 | $variables = $this->parser->getExpressionParser()->parseExpression(); 56 | } 57 | 58 | $stream->expect(/* Token::BLOCK_END_TYPE */3); 59 | 60 | return [$variables, $name]; 61 | } 62 | 63 | public function parseComponentName(): string 64 | { 65 | $stream = $this->parser->getStream(); 66 | 67 | $path = []; 68 | do { 69 | if ($this->parser->getCurrentToken()->getType() != /** Token::NAME_TYPE */ 5) { 70 | throw new Exception('First token must be a name type'); 71 | } 72 | 73 | $name = $stream->next()->getValue(); 74 | 75 | while ($stream->nextIf(Token::OPERATOR_TYPE, '-')) { 76 | $token = $stream->nextIf(Token::NAME_TYPE); 77 | if (! is_null($token)) { 78 | $name .= '-' . $token->getValue(); 79 | } 80 | } 81 | 82 | $path[] = $name; 83 | } while ($stream->nextIf(9 /** Token::PUNCTUATION_TYPE */, '.')); 84 | 85 | return implode('/', $path); 86 | } 87 | 88 | public function decideBlockEnd(Token $token): bool 89 | { 90 | return $token->test('endx'); 91 | } 92 | 93 | public function getTag(): string 94 | { 95 | return 'x'; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/ComponentNode.php: -------------------------------------------------------------------------------- 1 | setAttribute('path', $path); 18 | $this->setNode('slot', $slot); 19 | } 20 | 21 | public function compile(Compiler $compiler): void 22 | { 23 | $compiler->addDebugInfo($this); 24 | 25 | $template = $compiler->getVarName(); 26 | 27 | $compiler->write(sprintf("$%s = ", $template)); 28 | 29 | $this->addGetTemplate($compiler); 30 | 31 | $compiler 32 | ->write(sprintf("if ($%s) {\n", $template)) 33 | ->indent(1) 34 | ->write('$slotsStack = $slotsStack ?? [];' . PHP_EOL) 35 | ->write('$slotsStack[] = $slots ?? [];' . PHP_EOL) 36 | ->write('$slots = [];' . PHP_EOL) 37 | ->write("ob_start();" . PHP_EOL) 38 | ->subcompile($this->getNode('slot')) 39 | ->write("\$slots['slot'] = new " . SlotBag::class . "(ob_get_clean());" . PHP_EOL) 40 | ->write('$attributes = ' . ComponentUtilities::class . '::camelCaseKeys('); 41 | 42 | if ($this->hasNode('variables')) { 43 | $compiler->subcompile($this->getNode('variables'), true); 44 | } else { 45 | $compiler->raw('[]'); 46 | } 47 | 48 | $compiler 49 | ->raw(');' . PHP_EOL) 50 | ->write(sprintf('$%s->display(', $template)) 51 | ; 52 | 53 | $this->addTemplateArguments($compiler); 54 | 55 | $compiler 56 | ->write(');' . PHP_EOL) 57 | ->write('$slots = array_pop($slotsStack);' . PHP_EOL) 58 | ->indent(-1) 59 | ->write('}' . PHP_EOL) 60 | ; 61 | } 62 | 63 | protected function addGetTemplate(Compiler $compiler) 64 | { 65 | $compiler 66 | ->raw('$this->loadTemplate(' . PHP_EOL) 67 | ->indent(1) 68 | ->write('') 69 | ->repr($this->getTemplateName()) 70 | ->raw(', ' . PHP_EOL) 71 | ->write('') 72 | ->repr($this->getTemplateName()) 73 | ->raw(', ' . PHP_EOL) 74 | ->write('') 75 | ->repr($this->getTemplateLine()) 76 | ->indent(-1) 77 | ->raw(PHP_EOL) 78 | ->write(');' . PHP_EOL . PHP_EOL) 79 | ; 80 | } 81 | 82 | public function getTemplateName(): ?string 83 | { 84 | return $this->getAttribute('path'); 85 | } 86 | 87 | protected function addTemplateArguments(Compiler $compiler) 88 | { 89 | $compiler 90 | ->indent(1) 91 | ->raw(PHP_EOL) 92 | ->write('array_merge(' . PHP_EOL) 93 | ->indent(1) 94 | ->write('$slots,' . PHP_EOL) 95 | ->write('[' . PHP_EOL) 96 | ->indent(1) 97 | ->write("'_inherited' => new " . AttributesBag::class . '($context),' . PHP_EOL) 98 | ->write("'attributes' => new " . AttributesBag::class . '($attributes)' . PHP_EOL) 99 | ->indent(-1) 100 | ->write('],' . PHP_EOL) 101 | ->write('$attributes' . PHP_EOL) 102 | ->indent(-1) 103 | ->raw(PHP_EOL) 104 | ->write(')' . PHP_EOL) 105 | ->indent(-1); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twig components extension 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/performing/twig-components.svg?style=flat-square)](https://packagist.org/packages/performing/twig-components) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/giorgiopogliani/twig-components/Tests)](https://github.com/giorgiopogliani/twig-components/actions?query=workflow%3ATests+branch%3Amaster) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/performing/twig-components.svg?style=flat-square)](https://packagist.org/packages/performing/twig-components) 6 | 7 | This is a twig extension for automatically create components as tags. The name of the tag is based on the filename and the path. This is highly inspired from laravel blade components. 8 | 9 | ## Installation 10 | 11 | You can install the package via composer: 12 | 13 | ```bash 14 | composer require performing/twig-components 15 | ``` 16 | 17 | ## Setup 18 | **Update!** You can use just this line of code to setup the extension, this will enable: 19 | - components from the specified directory 20 | - safe filters so no need to use `|raw` 21 | - html-like as x-tags 22 | ```php 23 | \Performing\TwigComponents\Setup::init($twig, '/relative/directory/to/components'); 24 | ``` 25 | 26 | You want can still use the old method: 27 | ```php 28 | $extension = new \Performing\TwigComponents\ComponentExtension('/relative/twig/components/directory'); 29 | $twig->addExtension($extension); 30 | ``` 31 | 32 | ## Syntax 33 | 34 | You can use a component `component-name.twig` like this anywhere in your templates. 35 | ```twig 36 | {% x:component-name with {any: 'param'} %} 37 | Any Content 38 | {% endx %} 39 | ``` 40 | 41 | You can also reach for components files that are in **sub-folders** with a dot-notation syntax. For example, a component at `/components/button/primary.twig` would become 42 | ```twig 43 | {% x:button.primary with {any: 'param'} %} 44 | Any Content 45 | {% endx %} 46 | ``` 47 | 48 | ## Components 49 | You can create a file in the components directory like this: 50 | ```html 51 | {# /components/button.twig #} 52 | 55 | ``` 56 | 57 | Next, use the new component anywhere in your templates with this syntax or with x-tags. 58 | ```twig 59 | {# /index.twig #} 60 | {% x:button with {class:'bg-blue-600'} %} 61 | Click here! 62 | {% endx %} 63 | 64 | {# or #} 65 | 66 | 67 | Click here! 68 | 69 | ``` 70 | 71 | The output generated will be like this. 72 | ```html 73 | 76 | ``` 77 | 78 | ## Features 79 | 80 | ### Slots (0.2.0) 81 | ```twig 82 | {% x:card %} 83 | {% slot:title %} Some Title {% endslot %} 84 | 85 | Some content {# normal slot variable #} 86 | 87 | {% slot:buttons %} 88 | {% x:button.primary %} Submit {% endx %} 89 | {% endslot %} 90 | {% endx %} 91 | ``` 92 | 93 | ### Pro Tip (VSCode) 94 | Add this your user twig.json snippets 95 | ``` 96 | "Component": { 97 | "prefix": "x:", 98 | "body": [ 99 | "{% x:$1 %}", 100 | "$2", 101 | "{% endx %}", 102 | ], 103 | "description": "Twig component" 104 | } 105 | ``` 106 | 107 | ### X Tags (0.3.0) 108 | Now, you can enable `` for your twig components, here an example: 109 | ```html 110 | 111 | 112 | 113 | 114 | Submit 115 | 116 | ``` 117 | 118 | You can pass attributes like this: 119 | ```html 120 | 126 | 127 | 128 | 129 | Submit 130 | 131 | ``` 132 | 133 | To enable this feature you need to set the lexer on your twig enviroment. 134 | ```php 135 | $twig->setLexer(new Performing\TwigComponents\ComponentLexer($twig)); 136 | ``` 137 | 138 | **Keep in mind** that you should set the lexer after you register all your twig extensions. 139 | 140 | ### Craft CMS 141 | For example in Craft CMS you should do something like this. The `if` statement ensure you don't get `'Unable to register extension "..." as extensions have already been initialized'` as error. 142 | ```php 143 | // Module.php 144 | 145 | if (Craft::$app->request->getIsSiteRequest()) { 146 | Event::on( 147 | Plugins::class, 148 | Plugins::EVENT_AFTER_LOAD_PLUGINS, 149 | function (Event $event) { 150 | $twig = Craft::$app->getView()->getTwig(); 151 | \Performing\TwigComponents\Setup::init($twig, '/components'); 152 | } 153 | ); 154 | } 155 | ``` 156 | 157 | ## Testing 158 | 159 | ```bash 160 | composer test 161 | ``` 162 | 163 | ## License 164 | 165 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 166 | -------------------------------------------------------------------------------- /src/AttributesBag.php: -------------------------------------------------------------------------------- 1 | attributes = $attributes; 27 | } 28 | 29 | /** 30 | * Get the first attribute's value. 31 | * 32 | * @param mixed $default 33 | * @return mixed 34 | */ 35 | public function first($default = null) 36 | { 37 | return $this->getIterator()->current() ?? $default; 38 | } 39 | 40 | /** 41 | * Get a given attribute from the attribute array. 42 | * 43 | * @param string $key 44 | * @param mixed $default 45 | * @return mixed 46 | */ 47 | public function get($key, $default = '') 48 | { 49 | return $this->attributes[$key] ?? $default; 50 | } 51 | 52 | /** 53 | * Get a given attribute from the attribute array. 54 | * 55 | * @param string $key 56 | * @return bool 57 | */ 58 | public function has($key) 59 | { 60 | return array_key_exists($key, $this->attributes); 61 | } 62 | 63 | /** 64 | * Only include the given attribute from the attribute array. 65 | * 66 | * @param mixed|array $keys 67 | * @return static 68 | */ 69 | public function only($keys) 70 | { 71 | if (is_null($keys)) { 72 | $values = $this->attributes; 73 | } else { 74 | $keys = is_array($keys) ? $keys : [$keys]; 75 | 76 | $values = array_filter( 77 | $this->attributes, 78 | function ($key) use ($keys) { 79 | return in_array($key, $keys); 80 | }, 81 | ARRAY_FILTER_USE_KEY 82 | ); 83 | } 84 | 85 | return new static($values); 86 | } 87 | 88 | /** 89 | * Exclude the attributes given from the attribute array. 90 | * 91 | * @param mixed|array keys 92 | * @return static 93 | */ 94 | public function except($keys) 95 | { 96 | if (is_null($keys)) { 97 | $values = $this->attributes; 98 | } else { 99 | $keys = is_array($keys) ? $keys : [$keys]; 100 | 101 | $values = array_filter( 102 | $this->attributes, 103 | function ($key) use ($keys) { 104 | return ! in_array($key, $keys); 105 | }, 106 | ARRAY_FILTER_USE_KEY 107 | ); 108 | } 109 | 110 | return new static($values); 111 | } 112 | 113 | /** 114 | * Merge additional attributes / values into the attribute bag. 115 | * 116 | * @param array $attributeDefaults 117 | * @return static 118 | */ 119 | public function merge(array $attributeDefaults = []) 120 | { 121 | $attributes = $this->getAttributes(); 122 | 123 | foreach ($attributeDefaults as $key => $value) { 124 | if (! array_key_exists($key, $attributes)) { 125 | $attributes[$key] = ''; 126 | } 127 | } 128 | 129 | foreach ($attributes as $key => $value) { 130 | $attributes[$key] = trim($value . ' ' . ($attributeDefaults[$key] ?? '')); 131 | } 132 | 133 | return new static($attributes); 134 | } 135 | 136 | /** 137 | * Determine if the specific attribute value should be escaped. 138 | * 139 | * @param bool $escape 140 | * @param mixed $value 141 | * @return bool 142 | */ 143 | protected function shouldEscapeAttributeValue($escape, $value) 144 | { 145 | if (! $escape) { 146 | return false; 147 | } 148 | 149 | return ! is_object($value) && 150 | ! is_null($value) && 151 | ! is_bool($value); 152 | } 153 | 154 | /** 155 | * Get all of the raw attributes. 156 | * 157 | * @return array 158 | */ 159 | public function getAttributes() 160 | { 161 | return $this->attributes; 162 | } 163 | 164 | /** 165 | * Set the underlying attributes. 166 | * 167 | * @param array $attributes 168 | * @return void 169 | */ 170 | public function setAttributes(array $attributes) 171 | { 172 | if (isset($attributes['attributes']) && 173 | $attributes['attributes'] instanceof self) { 174 | $parentBag = $attributes['attributes']; 175 | 176 | unset($attributes['attributes']); 177 | 178 | $attributes = $parentBag->merge($attributes, $escape = false)->getAttributes(); 179 | } 180 | 181 | $this->attributes = $attributes; 182 | } 183 | 184 | /** 185 | * Determine if the given offset exists. 186 | * 187 | * @param string $offset 188 | * @return bool 189 | */ 190 | public function offsetExists($offset) 191 | { 192 | return isset($this->attributes[$offset]); 193 | } 194 | 195 | /** 196 | * Get the value at the given offset. 197 | * 198 | * @param string $offset 199 | * @return mixed 200 | */ 201 | public function offsetGet($offset) 202 | { 203 | return $this->get($offset); 204 | } 205 | 206 | /** 207 | * Set the value at a given offset. 208 | * 209 | * @param string $offset 210 | * @param mixed $value 211 | * @return void 212 | */ 213 | public function offsetSet($offset, $value) 214 | { 215 | $this->attributes[$offset] = $value; 216 | } 217 | 218 | /** 219 | * Remove the value at the given offset. 220 | * 221 | * @param string $offset 222 | * @return void 223 | */ 224 | public function offsetUnset($offset) 225 | { 226 | unset($this->attributes[$offset]); 227 | } 228 | 229 | /** 230 | * Get an iterator for the items. 231 | * 232 | * @return \ArrayIterator 233 | */ 234 | public function getIterator() 235 | { 236 | return new ArrayIterator($this->attributes); 237 | } 238 | 239 | /** 240 | * Implode the attributes into a single HTML ready string. 241 | * 242 | * @return string 243 | */ 244 | public function __toString() 245 | { 246 | $string = ''; 247 | 248 | foreach ($this->attributes as $key => $value) { 249 | if ($value === false || is_null($value)) { 250 | continue; 251 | } 252 | 253 | if ($value === true) { 254 | $value = $key; 255 | } 256 | 257 | $string .= ' ' . $key . '="' . str_replace('"', '\\"', trim($value)) . '"'; 258 | } 259 | 260 | return trim($string); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/ComponentTagCompiler.php: -------------------------------------------------------------------------------- 1 | to twig component tags 7 | * Most parts shamelessly stolen form Laravel's ComponentTagCompiler 8 | */ 9 | class ComponentTagCompiler 10 | { 11 | protected $source; 12 | 13 | public function __construct(string $source) 14 | { 15 | $this->source = $source; 16 | } 17 | 18 | public function compile(): string 19 | { 20 | $value = $this->source; 21 | $value = $this->compileSlots($value); 22 | $value = $this->compileSelfClosingTags($value); 23 | $value = $this->compileOpeningTags($value); 24 | $value = $this->compileClosingTags($value); 25 | 26 | return $value; 27 | } 28 | 29 | /** 30 | * Compile the slot tags within the given string. 31 | * 32 | * @param string $value 33 | * @return string 34 | */ 35 | public function compileSlots(string $value) 36 | { 37 | $value = preg_replace_callback('/<\s*x[\-\:]slot\s+(:?)name=(?(\"[^\"]+\"|\\\'[^\\\']+\\\'|[^\s>]+))\s*>/', function ($matches) { 38 | $name = $this->stripQuotes($matches['name']); 39 | 40 | return "{% slot:$name %}"; 41 | }, $value); 42 | 43 | return preg_replace('/<\/\s*x[\-\:]slot[^>]*>/', '{% endslot %}', $value); 44 | } 45 | 46 | /** 47 | * Compile the opening tags within the given string. 48 | * 49 | * @param string $value 50 | * 51 | * @return string 52 | * 53 | * @throws \InvalidArgumentException 54 | */ 55 | protected function compileOpeningTags(string $value) 56 | { 57 | $pattern = "/ 58 | < 59 | \s* 60 | x[-\:]([\w\-\:\.]*) 61 | (? 62 | (?: 63 | \s+ 64 | (?: 65 | (?: 66 | \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} 67 | ) 68 | | 69 | (?: 70 | [\w\-:.@]+ 71 | ( 72 | = 73 | (?: 74 | \\\"[^\\\"]*\\\" 75 | | 76 | \'[^\']*\' 77 | | 78 | [^\'\\\"=<>]+ 79 | ) 80 | )? 81 | ) 82 | ) 83 | )* 84 | \s* 85 | ) 86 | (? 88 | /x"; 89 | 90 | return preg_replace_callback( 91 | $pattern, 92 | function (array $matches) { 93 | $attributes = $this->getAttributesFromAttributeString($matches['attributes']); 94 | $name = $matches[1]; 95 | 96 | return "{% x:$name with $attributes %}"; 97 | }, 98 | $value 99 | ); 100 | } 101 | 102 | /** 103 | * Compile the closing tags within the given string. 104 | * 105 | * @param string $value 106 | * 107 | * @return string 108 | */ 109 | protected function compileClosingTags(string $value) 110 | { 111 | return preg_replace("/<\/\s*x[-\:][\w\-\:\.]*\s*>/", '{% endx %}', $value); 112 | } 113 | 114 | /** 115 | * Compile the self-closing tags within the given string. 116 | * 117 | * @param string $value 118 | * 119 | * @return string 120 | * 121 | * @throws \InvalidArgumentException 122 | */ 123 | protected function compileSelfClosingTags(string $value) 124 | { 125 | $pattern = "/ 126 | < 127 | \s* 128 | x[-\:]([\w\-\:\.]*) 129 | \s* 130 | (? 131 | (?: 132 | \s+ 133 | (?: 134 | (?: 135 | \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} 136 | ) 137 | | 138 | (?: 139 | [\w\-:.@]+ 140 | ( 141 | = 142 | (?: 143 | \\\"[^\\\"]*\\\" 144 | | 145 | \'[^\']*\' 146 | | 147 | [^\'\\\"=<>]+ 148 | ) 149 | )? 150 | ) 151 | ) 152 | )* 153 | \s* 154 | ) 155 | \/> 156 | /x"; 157 | 158 | return preg_replace_callback( 159 | $pattern, 160 | function (array $matches) { 161 | $attributes = $this->getAttributesFromAttributeString($matches['attributes']); 162 | $name = $matches[1]; 163 | 164 | return "{% x:$name with $attributes %}{% endx %}"; 165 | }, 166 | $value 167 | ); 168 | } 169 | 170 | protected function getAttributesFromAttributeString(string $attributeString) 171 | { 172 | $attributeString = $this->parseAttributeBag($attributeString); 173 | 174 | $pattern = '/ 175 | (?[\w\-:.@]+) 176 | ( 177 | = 178 | (? 179 | ( 180 | \"[^\"]+\" 181 | | 182 | \\\'[^\\\']+\\\' 183 | | 184 | [^\s>]+ 185 | ) 186 | ) 187 | )? 188 | /x'; 189 | 190 | if (! preg_match_all($pattern, $attributeString, $matches, PREG_SET_ORDER)) { 191 | return '{}'; 192 | } 193 | 194 | 195 | $attributes = []; 196 | 197 | foreach ($matches as $match) { 198 | $attribute = $match['attribute']; 199 | $value = $match['value'] ?? null; 200 | 201 | if (is_null($value)) { 202 | $value = 'true'; 203 | } 204 | 205 | 206 | if (strpos($attribute, ":") === 0) { 207 | $attribute = str_replace(":", "", $attribute); 208 | $value = $this->stripQuotes($value); 209 | } 210 | 211 | $valueWithoutQuotes = $this->stripQuotes($value); 212 | 213 | if ((strpos($valueWithoutQuotes, '{{') === 0) && (strpos($valueWithoutQuotes, '}}') === strlen($valueWithoutQuotes) - 2)) { 214 | $value = substr($valueWithoutQuotes, 2, -2); 215 | } else { 216 | $value = $value; 217 | } 218 | 219 | $attributes[$attribute] = $value; 220 | } 221 | 222 | $out = "{"; 223 | foreach ($attributes as $key => $value) { 224 | $key = "'$key'"; 225 | $out .= "$key: $value,"; 226 | }; 227 | 228 | return rtrim($out, ',') . "}"; 229 | } 230 | 231 | /** 232 | * Strip any quotes from the given string. 233 | * 234 | * @param string $value 235 | * @return string 236 | */ 237 | public function stripQuotes(string $value) 238 | { 239 | return strpos($value, '"') === 0 || strpos($value, '\'') === 0 240 | ? substr($value, 1, -1) 241 | : $value; 242 | } 243 | 244 | /** 245 | * Parse the attribute bag in a given attribute string into it's fully-qualified syntax. 246 | * 247 | * @param string $attributeString 248 | * @return string 249 | */ 250 | protected function parseAttributeBag(string $attributeString) 251 | { 252 | $pattern = "/ 253 | (?:^|\s+) # start of the string or whitespace between attributes 254 | \{\{\s*(\\\$attributes(?:[^}]+?(?