├── 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 | [](https://packagist.org/packages/performing/twig-components)
4 | [](https://github.com/giorgiopogliani/twig-components/actions?query=workflow%3ATests+branch%3Amaster)
5 | [](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(?:[^}]+?(?