├── .gitignore
├── CONTRIBUTING
├── LICENSE
├── README.md
├── composer.json
├── docs
├── Aliases.md
├── Attributes.md
└── Props.md
├── phpstan.neon
├── src
├── Concern
│ └── IsArrayAccessibleAndCountable.php
├── Exception
│ ├── BrickException.php
│ ├── PropExpectedTypeStringInvalidException.php
│ ├── PropHasUnexpectedTypeException.php
│ └── PropIsMissingException.php
├── Model
│ ├── BrickAttributesBag.php
│ ├── BrickPropsBag.php
│ └── Mason.php
├── Plugin
│ └── TemplateEngine
│ │ └── PhpPlugin.php
├── etc
│ ├── di.xml
│ ├── frontend
│ │ └── di.xml
│ └── module.xml
├── registration.php
└── view
│ └── base
│ └── templates
│ ├── cms
│ └── block.phtml
│ └── elements
│ └── link
│ └── external.phtml
└── tests
└── Unit
├── BrickAttributesBagTest.php
├── BrickPropsBagTest.php
└── MasonTest.php
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/magento2,phpstorm,macos,linux
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=magento2,phpstorm,macos,linux
3 |
4 | ### Linux ###
5 | *~
6 |
7 | # temporary files which can be created if a process still has a handle open of a deleted file
8 | .fuse_hidden*
9 |
10 | # KDE directory preferences
11 | .directory
12 |
13 | # Linux trash folder which might appear on any partition or disk
14 | .Trash-*
15 |
16 | # .nfs files are created when an open file is removed but is still being accessed
17 | .nfs*
18 |
19 | ### macOS ###
20 | # General
21 | .DS_Store
22 | .AppleDouble
23 | .LSOverride
24 |
25 | # Icon must end with two \r
26 | Icon
27 |
28 |
29 | # Thumbnails
30 | ._*
31 |
32 | # Files that might appear in the root of a volume
33 | .DocumentRevisions-V100
34 | .fseventsd
35 | .Spotlight-V100
36 | .TemporaryItems
37 | .Trashes
38 | .VolumeIcon.icns
39 | .com.apple.timemachine.donotpresent
40 |
41 | # Directories potentially created on remote AFP share
42 | .AppleDB
43 | .AppleDesktop
44 | Network Trash Folder
45 | Temporary Items
46 | .apdisk
47 |
48 | ### macOS Patch ###
49 | # iCloud generated files
50 | *.icloud
51 |
52 | ### Magento2 ###
53 | /.buildpath
54 | /.cache
55 | /.metadata
56 | /.project
57 | /.settings
58 | /.vscode
59 | atlassian*
60 | /nbproject
61 | /robots.txt
62 | /pub/robots.txt
63 | /sitemap
64 | /sitemap.xml
65 | /pub/sitemap
66 | /pub/sitemap.xml
67 | /.idea
68 | /.gitattributes
69 | /app/config_sandbox
70 | /app/etc/config.php
71 | /app/etc/env.php
72 | /app/code/Magento/TestModule*
73 | /lib/internal/flex/uploader/.actionScriptProperties
74 | /lib/internal/flex/uploader/.flexProperties
75 | /lib/internal/flex/uploader/.project
76 | /lib/internal/flex/uploader/.settings
77 | /lib/internal/flex/varien/.actionScriptProperties
78 | /lib/internal/flex/varien/.flexLibProperties
79 | /lib/internal/flex/varien/.project
80 | /lib/internal/flex/varien/.settings
81 | /node_modules
82 | /.grunt
83 | /Gruntfile.js
84 | /package.json
85 | /.php_cs
86 | /.php_cs.cache
87 | /grunt-config.json
88 | /pub/media/*.*
89 | !/pub/media/.htaccess
90 | /pub/media/attribute/*
91 | !/pub/media/attribute/.htaccess
92 | /pub/media/analytics/*
93 | /pub/media/catalog/*
94 | !/pub/media/catalog/.htaccess
95 | /pub/media/customer/*
96 | !/pub/media/customer/.htaccess
97 | /pub/media/downloadable/*
98 | !/pub/media/downloadable/.htaccess
99 | /pub/media/favicon/*
100 | /pub/media/import/*
101 | !/pub/media/import/.htaccess
102 | /pub/media/logo/*
103 | /pub/media/custom_options/*
104 | !/pub/media/custom_options/.htaccess
105 | /pub/media/theme/*
106 | /pub/media/theme_customization/*
107 | !/pub/media/theme_customization/.htaccess
108 | /pub/media/wysiwyg/*
109 | !/pub/media/wysiwyg/.htaccess
110 | /pub/media/tmp/*
111 | !/pub/media/tmp/.htaccess
112 | /pub/media/captcha/*
113 | /pub/static/*
114 | !/pub/static/.htaccess
115 |
116 | /var/*
117 | !/var/.htaccess
118 | /vendor/*
119 | !/vendor/.htaccess
120 | /generated/*
121 | !/generated/.htaccess
122 |
123 | ### PhpStorm ###
124 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
125 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
126 |
127 | # User-specific stuff
128 | .idea/**/workspace.xml
129 | .idea/**/tasks.xml
130 | .idea/**/usage.statistics.xml
131 | .idea/**/dictionaries
132 | .idea/**/shelf
133 |
134 | # AWS User-specific
135 | .idea/**/aws.xml
136 |
137 | # Generated files
138 | .idea/**/contentModel.xml
139 |
140 | # Sensitive or high-churn files
141 | .idea/**/dataSources/
142 | .idea/**/dataSources.ids
143 | .idea/**/dataSources.local.xml
144 | .idea/**/sqlDataSources.xml
145 | .idea/**/dynamic.xml
146 | .idea/**/uiDesigner.xml
147 | .idea/**/dbnavigator.xml
148 |
149 | # Gradle
150 | .idea/**/gradle.xml
151 | .idea/**/libraries
152 |
153 | # Gradle and Maven with auto-import
154 | # When using Gradle or Maven with auto-import, you should exclude module files,
155 | # since they will be recreated, and may cause churn. Uncomment if using
156 | # auto-import.
157 | # .idea/artifacts
158 | # .idea/compiler.xml
159 | # .idea/jarRepositories.xml
160 | # .idea/modules.xml
161 | # .idea/*.iml
162 | # .idea/modules
163 | # *.iml
164 | # *.ipr
165 |
166 | # CMake
167 | cmake-build-*/
168 |
169 | # Mongo Explorer plugin
170 | .idea/**/mongoSettings.xml
171 |
172 | # File-based project format
173 | *.iws
174 |
175 | # IntelliJ
176 | out/
177 |
178 | # mpeltonen/sbt-idea plugin
179 | .idea_modules/
180 |
181 | # JIRA plugin
182 | atlassian-ide-plugin.xml
183 |
184 | # Cursive Clojure plugin
185 | .idea/replstate.xml
186 |
187 | # SonarLint plugin
188 | .idea/sonarlint/
189 |
190 | # Crashlytics plugin (for Android Studio and IntelliJ)
191 | com_crashlytics_export_strings.xml
192 | crashlytics.properties
193 | crashlytics-build.properties
194 | fabric.properties
195 |
196 | # Editor-based Rest Client
197 | .idea/httpRequests
198 |
199 | # Android studio 3.1+ serialized cache file
200 | .idea/caches/build_file_checksums.ser
201 |
202 | ### PhpStorm Patch ###
203 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
204 |
205 | # *.iml
206 | # modules.xml
207 | # .idea/misc.xml
208 | # *.ipr
209 |
210 | # Sonarlint plugin
211 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
212 | .idea/**/sonarlint/
213 |
214 | # SonarQube Plugin
215 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
216 | .idea/**/sonarIssues.xml
217 |
218 | # Markdown Navigator plugin
219 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
220 | .idea/**/markdown-navigator.xml
221 | .idea/**/markdown-navigator-enh.xml
222 | .idea/**/markdown-navigator/
223 |
224 | # Cache file creation bug
225 | # See https://youtrack.jetbrains.com/issue/JBR-2257
226 | .idea/$CACHE_FILE$
227 |
228 | # CodeStream plugin
229 | # https://plugins.jetbrains.com/plugin/12206-codestream
230 | .idea/codestream.xml
231 |
232 | # Azure Toolkit for IntelliJ plugin
233 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
234 | .idea/**/azureSettings.xml
235 |
236 | # End of https://www.toptal.com/developers/gitignore/api/magento2,phpstorm,macos,linux
237 |
--------------------------------------------------------------------------------
/CONTRIBUTING:
--------------------------------------------------------------------------------
1 | Contributions are appreciated. You can reach out to me directly, or open an issue or pull request.
2 |
3 | You can help by:
4 | - Raising an issue or concern, with steps how to reproduce
5 | - Requesting useful and viable features
6 | - Writing a (failing) test
7 | - Submitting a pull request
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 LBannenberg
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Layout Bricks for Magento
2 |
3 |
4 | [](https://packagist.org/packages/corrivate/magento2-layout-bricks)
5 | [](LICENSE.md)
6 |
7 | *All in all you're just another brick in the layout*
8 |
9 | ```bash
10 | composer require corrivate/magento2-layout-bricks
11 | ```
12 |
13 | Modern frontend frameworks embrace reusable components, like buttons, input fields, and cards. And they style them with utility CSS like Tailwind. It's fine to pile a dozen classes on that primary button, because you only have to build it once.
14 |
15 | Magento doesn't come with this out of the box. Many templates are *huge* and if you talk about UI components people make the sign of the ~~cross~~ XML at you.
16 |
17 | This package is a way to make things better. To use small anonymous components without hassle. It's heavily inspired by Laravel's anonymous blade components. In Magento, our unit of frontend template is a block. An anonymous block is a **brick**.
18 |
19 | ## An example phtml template
20 | ```php
21 |
26 |
27 |
38 | ```
39 |
40 | ## How does it work?
41 |
42 | The `$mason` object is globally injected into every `.phtml` template. It has just one method, `__invoke()`, to cause it to output as a string a fully rendered child block. So it's essentially a compact, ergonomic way of doing this:
43 |
44 | ```php
45 | = $block
46 | ->getLayout()
47 | ->createBlock(\Magento\Framework\View\Element\Template::class)
48 | ->setTemplate($template)
49 | ?>
50 | ```
51 |
52 | This is already nice, because we are now still using Magento's templating engine:
53 | * We can call small templates without using a ton of boilerplate. It's now realistic to make a template for something as small as a single button. So we can re-use the same button look and feel throughout the entire website. This is really helpful if the button actually has a LOT of utility CSS classes. Hi Tailwind.
54 | * We can make a library of base components as a reusable module.
55 | * We still have the opportunity to use Magento's theme overrides. We can change the way buttons look in a website or single store. But because we're re-using the button template everywhere, we can change it in one place and have the change happen everywhere.
56 |
57 | But there's more:
58 |
59 | * You can set default HTML attributes (such as classes) on a component, and inject additional ones based on the context. They will be merged, with new properties overriding default ones.
60 | * You can inject props into a component, supplying them with data.
61 |
62 | For example, consider the `cms.block` brick:
63 | ```php
64 | = $mason('cms.block',
65 | attributes: ['class' => 'border-2 border-stone-400 rounded-lg'],
66 | props: ['block_id' => 'text-block'])
67 | ?>
68 | ```
69 |
70 | This will render the CMS block with ID 'text-block', but surround it in a div with a gray round border.
71 |
72 | ## Aliases
73 |
74 | [Aliases in detail](docs/Aliases.md)
75 |
76 | You can place bricks in two ways:
77 | * Fully cite the Magento template path:
78 |
79 | ```php
80 | = $mason('Corrivate_LayoutBricks::cms/block.phtml', props: ['block_id' => 'test-block']) ?>
81 | ```
82 |
83 | * Create an **alias** for it, so you can refer to it more shortly:
84 |
85 | ```php
86 | = $mason('cms.block', props: ['block_id' => 'test-block']) ?>
87 | ```
88 |
89 | [Aliases in detail](docs/Aliases.md)
90 |
91 | ## Attributes
92 |
93 | [Attributes in detail](docs/Attributes.md)
94 |
95 | The `$mason` objects invoke method accepts an array of attributes. For example:
96 |
97 | ```php
98 | = $mason('input.text', attributes: [
99 | 'required',
100 | 'disabled' => false,
101 | 'class' => 'text-black',
102 | 'placeholder' => 'your input please',
103 | 'name' => 'user_comment'
104 | ]) ?>
105 | ```
106 |
107 | In the brick template, this will be available as a BrickAttributesBag which could for example have the following default attributes/values:
108 |
109 | ```php
110 | default([
111 | 'class' =>
112 | 'bg-white',
113 | 'disabled' => true,
114 | 'type' => 'text'
115 | ]) ?> />
116 | ```
117 |
118 | This would result in the following HTML after the defaults and your custom input is merged:
119 |
120 | ```html
121 |
126 | ```
127 |
128 | [Attributes in detail](docs/Attributes.md)
129 |
130 | ## Props
131 |
132 | [Props in detail](docs/Props.md)
133 |
134 | Props are used to pass data to the brick. For example, if you were making a brick to render a "product card", you'd pass the product that needs to be displayed:
135 |
136 | ```php
137 | = $mason('product-cart', props: ['product' => $product]) ?>
138 | ```
139 |
140 | In the brick template, the props are available through the `$props` variable, which is automatically present:
141 |
142 | ```php
143 |
149 |
150 |
151 | = $props['product']->getSku() ?>
152 |
153 |
154 | ```
155 |
156 | The `$props` variable is not an array, but it implements `ArrayAccess` to give access to its contents.
157 |
158 | The `$props` variable also has a `$props->default([])` method so you can supply default (scalar) props. You can always override those default from the parent template.
159 |
160 | The `$props` object also has a `$props->expect([])` method which allows you to specify expected props and their data types so you can opt into greater type safety.
161 |
162 | [Props in detail](docs/Props.md)
163 |
164 |
165 | ## Escaper
166 | Using `$escaper` to filter raw output of data from the DB or user input is important to protect against various attacks. [Official documentation](https://developer.adobe.com/commerce/php/development/security/cross-site-scripting/#phtml-templates) about this.
167 |
168 | However, the output from `$mason()` is the output of another block that already produces HTML. So the call to `$mason()` should NOT be escaped, but inside the PHTML template implementing your brick, you should use it normally.
169 |
170 | ## Empty PHTML brick template
171 |
172 | Just copy paste this into a file and start designing:
173 |
174 | ```php
175 | default([
185 | // you can supply default scalar values for your props
186 | ])->expect([
187 | // specify propName => type for your props
188 | ]);
189 | ?>
190 |
191 |
192 |
default(['class' => '']) ?>>
193 |
194 |
195 |
196 | ```
197 |
198 |
199 | ## Corrivate
200 | (en.wiktionary.org)
201 |
202 | Etymology
203 |
204 | From Latin *corrivatus*, past participle of *corrivare* ("to corrivate").
205 |
206 | ### Verb
207 |
208 | **corrivate** (*third-person singular simple present* **corrivates**, *present participle* **corrivating**, *simple past and past participle* **corrivated**)
209 |
210 | (*obsolete*) To cause to flow together, as water drawn from several streams.
211 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "corrivate/magento2-layout-bricks",
3 | "description": "Use layout bricks in Magento, inspired by Laravel anonymous Blade components",
4 | "type": "magento2-module",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Lau Bannenberg",
9 | "email": "lau.bannenberg@gmail.com"
10 | }
11 | ],
12 | "minimum-stability": "stable",
13 | "require": {
14 | "php": "~7.4.0||~8.0.0||~8.1.0||~8.2.0||~8.3.0",
15 | "magento/framework": "*",
16 | "magento/module-cms": "*"
17 | },
18 | "require-dev": {
19 | "bitexpert/phpstan-magento": "^0.32.0",
20 | "phpstan/extension-installer": "^1.4",
21 | "phpstan/phpstan": "^1.12"
22 | },
23 | "autoload": {
24 | "files": [
25 | "src/registration.php"
26 | ],
27 | "psr-4": {
28 | "Corrivate\\LayoutBricks\\": "src/"
29 | }
30 | },
31 | "autoload-dev": {
32 | "psr-4": {
33 | "Corrivate\\LayoutBricks\\": "tests/"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/docs/Aliases.md:
--------------------------------------------------------------------------------
1 | # Aliases
2 |
3 | Aliases are created by injecting the with `frontend/di.xml` into the `\Corrivate\LayoutBricks\Model\Mason` constructor's `aliases` array:
4 |
5 | ```xml
6 |
7 |
9 |
10 |
11 |
12 | Corrivate_LayoutBricks::cms/block.phtml
13 |
14 |
15 |
16 |
17 | ```
18 |
19 | It's important to target the `frontend/di.xml` (or `adminhtml/di.xml`) files, not the base `etc/di.xml` file, because the frontend injected dependencies will cause any deps you try to inject in the base area to be ignored.
20 |
--------------------------------------------------------------------------------
/docs/Attributes.md:
--------------------------------------------------------------------------------
1 | # Attributes
2 |
3 | ## Merging default and injected attributes
4 |
5 | The module provides a (Laravel-inspired) way to combine default and customized HTML attributes. From the outside, this looks like this:
6 |
7 | ```PHP
8 |
9 |
12 | ```
13 |
14 | And inside the component it would look like:
15 |
16 | ```PHP
17 |
23 |
24 |
27 | ```
28 |
29 | The resulting HTML would be:
30 |
31 | ```html
32 |
33 |
34 |
35 | ```
36 |
37 | As you can see, we get both the default attributes (type=button, text-black) as the injected ones (text-blue-800). Because the injected CSS classes are placed last, and assuming no weird specificity problems, they will have the last word.
38 |
39 | **NOTE** The `default()` method merges in-place, so if you call:
40 |
41 | ```php
42 | $attributes->default(['style' => 'display: none;']);
43 | echo $attributes;
44 | ```
45 |
46 | You'll get a `style="display: none;"`, because the `$attributes` object has been modified.
47 |
48 | ## Accessing a specific attribute
49 |
50 | The attributes are supplied by the BrickAttributesBag class, which is a data carrier that presents an ArrayAccess interface so you can also for example reach into it to grab a specific attribute:
51 |
52 | ```php
53 | $attributes['style'] = 'display: none;'
54 | echo $attributes['style'];
55 | ```
56 |
57 | ## Using `only` or `without` some attributes
58 |
59 | If you need a handful of attributes which may or may not have been set, you can use the `only` method (and it's counterpart, the `without` method):
60 |
61 | ```php
62 | echo $attributes->only('required', 'disabled', 'readonly');
63 | echo $attributes->without('required', 'disabled', 'readonly');
64 | ```
65 |
66 | These methods return a **new copy** of the `$attributes` with only the requested keys.
67 |
68 | ## Using `whereStartsWith` and `WhereDoesntStartWith`
69 |
70 | When working with Magewire and AlpineJS it may also be useful to filter attributes based on prefix:
71 |
72 | ```php
73 |
74 |
75 |
whereStartsWith('wire:') ?>>
76 | ...
77 |
78 |
79 |
whereDoesntStartWith('x-') ?> >
80 | ...
81 |
82 | ```
83 |
84 | These methods return a **new copy** of the `$attributes` with only the requested keys.
85 |
86 | ## Boolean HTML attributes
87 |
88 | Attributes like `required` are Boolean: either they're present on a HTML element and true, or they're completely absent. But we often want to set them based on conditions. We can do this:
89 |
90 | ```php
91 | = $mason('btn-primary', ['disabled' => $session->isLoggedIn()]) ?>
92 | ```
93 |
94 | Depending on whether `isLoggedIn()` turns out to be true, we would get either of these:
95 |
96 | ```html
97 |
98 |
99 | ```
100 |
101 | This follows the standard that Boolean HTML attributes should be simply present when true and absent when false.
102 |
103 | Note that it's also possible to just pass in straight strings, if you don't need extra logic:
104 |
105 | ```php
106 | = $mason('btn-primary', ['disabled']) ?>
107 | ```
108 | Will result in:
109 | ```html
110 |
111 | ```
112 |
--------------------------------------------------------------------------------
/docs/Props.md:
--------------------------------------------------------------------------------
1 | # Props
2 |
3 | Props are used to pass data to the brick component. In the template, it could look something like this:
4 |
5 | ```php
6 | default([ 'title' => 'My Product Card' ])
13 | ->expect([
14 | 'title' => 'string',
15 | 'special_price' => '?float',
16 | 'initial_quantity' => 'int|float|null'
17 | 'product' => \Magento\Catalog\Api\Data\ProductInterface::class
18 | ]);
19 |
20 | $product = $props['product'];
21 | ?>
22 |
23 |