├── .scrutinizer.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── grumphp.yml ├── phpcs.xml ├── phpstan.neon └── src ├── Plugin ├── CategoryLayoutPlugin.php ├── PageLayoutPlugin.php └── ProductLayoutPlugin.php ├── etc ├── di.xml └── module.xml └── registration.php /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: 3 | - 'tests/*' 4 | 5 | build: 6 | environment: 7 | php: 7.3 8 | tests: 9 | override: 10 | - true 11 | nodes: 12 | analysis: 13 | dependencies: 14 | after: 15 | - vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard/ 16 | tests: 17 | override: 18 | - php-scrutinizer-run 19 | - phpcs-run 20 | 21 | tools: 22 | external_code_coverage: 23 | runs: 1 # Scrutinizer will wait for one code coverage submission (integration test suite) 24 | timeout: 2400 # Timeout in seconds. 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## 1.1.2 - 2020-05-11 8 | ### Added 9 | - Updates travis, magento 2.4-dev now requires elasticsearch 10 | - Adds return type declaration (`void`) to `setUp()` functions, now needed for magento 2.4 11 | 12 | ## 1.1.1 - 2020-05-08 13 | ### Added 14 | - Adds module registration test (registration.php coverage) 15 | - Adds travis config for tags/releases 16 | 17 | ## 1.1.0 - 2020-05-02 18 | ### Added 19 | - Adds frontend test coverage for global custom layout updates 20 | - Fixes [#7](https://github.com/integer-net/magento2-global-custom-layout/issues/7) where layout handles were not merged in Product and Page Plugins' `afterFetchAvailableFiles()` method. 21 | 22 | ## 1.0.0 - 2020-04-27 23 | ### Added 24 | - Plugins for category, product and page layouts that allow custom layout files to be loaded with a global identifier 25 | (`0`). E.g. `catalog_category_view_selectable_0_.xml` for Categories. 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to IntegerNet_GlobalCustomLayout 2 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## We Develop with Github 11 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 12 | 13 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 14 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've added code that should be tested, add tests. 18 | 3. If you've changed APIs, update the documentation. 19 | 4. Ensure the test suite passes. 20 | 5. Make sure your code lints. 21 | 6. Issue that pull request! 22 | 23 | ## Any contributions you make will be under the MIT Software License 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](https://github.com/integer-net/magento2-global-custom-layout/issues) 27 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/integer-net/magento2-global-custom-layout/issues/new); it's that easy! 28 | 29 | ## Write bug reports with detail, background, and sample code 30 | [This is an example](http://stackoverflow.com/q/12488905/180626) of a bug report I wrote, and I think it's not a bad model. Here's [another example from Craig Hockenberry](http://www.openradar.me/11905408), an app developer whom I greatly respect. 31 | 32 | **Great Bug Reports** tend to have: 33 | 34 | - A quick summary and/or background 35 | - Steps to reproduce 36 | - Be specific! 37 | - Give sample code if you can. [My stackoverflow question](http://stackoverflow.com/q/12488905/180626) includes sample code that *anyone* with a base R setup can run to reproduce what I was seeing 38 | - What you expected would happen 39 | - What actually happens 40 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 41 | 42 | People *love* thorough bug reports. I'm not even kidding. 43 | 44 | ## Pull Requests 45 | 46 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 47 | 48 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 49 | 50 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 51 | 52 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 53 | 54 | - **Create feature branches** - Don't ask us to pull from your master branch. 55 | 56 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 57 | 58 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 59 | 60 | ## License 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | 63 | ## References 64 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) with additions from [ThePhpLeague Template](https://github.com/thephpleague/skeleton) 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 integer_net GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Integer_Net GlobalCustomLayout 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Build Status][ico-travis]][link-travis] 6 | [![Coverage Status][ico-scrutinizer]][link-scrutinizer] 7 | [![Quality Score][ico-code-quality]][link-code-quality] 8 | ![Supported Magento Versions][ico-compatibility] 9 | 10 | Allows you to add global layout update files to be selected from admin, by using `0` instead of a `category_id` / `sku` / `url_path`. 11 | 12 | Compatible with Magento 2.3.4 and higher, since **cms-page/product/category specific layouts** where introduced in this version. 13 | 14 | ## Purpose 15 | 16 | In Magento 2.3.4, [xml layout updates were removed from the Magento Admin](https://devdocs.magento.com/guides/v2.3/release-notes/release-notes-2-3-4-open-source.html#highlights), for security reasons. 17 | Previously this textfield allowed you to add XML Layout updates to any given Category, Product or CMS Page. 18 | After the update, this textfield is no longer available, but you can select custom layout updates which are defined in xml layout files in the filesystem. 19 | 20 | After uploading/deploying _selectable layout files_ onto your project's filesystem, these layouts can be selected from the admin under the **Design** section. 21 | The field is called **Custom Layout Update**. 22 | 23 | ## Usage: 24 | 25 | Replace identifiers in selectable layouts with a 0 (zero). 26 | Add layout file to themes/modules using: 27 | - catalog_category_view_selectable_0_.xml for Categories 28 | - catalog_product_view_selectable_0_.xml for Products 29 | - cms_page_view_selectable_0_.xml for Cms pages 30 | 31 | These files can go anywhere where you'd normally put layout files. For example: 32 | `app/design/frontend/[Theme_Vendor]/[Theme_Name]/Magento_Theme/layout/catalog_category_view_0_customchanges.xml` 33 | 34 | You can now select the layout update at _any_ given Category/Product/Page, under **Custom layout update** field of **Design**. 35 | 36 | More info on default behaviour of selectable layouts: 37 | [Magento DevDocs: Create cms-page/product/category-specific layouts](https://devdocs.magento.com/guides/v2.3/frontend-dev-guide/layouts/xml-manage.html#create-cms-pageproductcategory-specific-layouts) 38 | 39 | ## Installation 40 | 41 | 1. Install via composer 42 | ``` 43 | composer require integer-net/magento2-global-custom-layout 44 | ``` 45 | 2. Enable module 46 | ``` 47 | bin/magento setup:upgrade 48 | ``` 49 | ## Configuration 50 | 51 | Zero configuration needed. 52 | 53 | ## Change log 54 | 55 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 56 | 57 | ## Testing 58 | 59 | ### Magento Integration Tests 60 | 61 | 0. Configure test database in `dev/tests/integration/etc/install-config-mysql.php`. [Read more in the Magento docs.](https://devdocs.magento.com/guides/v2.3/test/integration/integration_test_execution.html) 62 | 63 | 1. Copy `Test/Integration/phpunit.xml.dist` from the package to `dev/tests/integration/phpunit.xml` in your Magento installation. 64 | 65 | 2. In that directory, run 66 | ``` bash 67 | ../../../vendor/bin/phpunit 68 | ``` 69 | 70 | ## Contributing 71 | 72 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 73 | 74 | ## Known issues 75 | 76 | 1. Does not work with the homepage (cms_index_index). But hey, it doesn't in the default Magento implementation either. 77 | 78 | ## Security 79 | 80 | If you discover any security related issues, please email ww@integer-net.de instead of using the issue tracker. 81 | 82 | ## Credits 83 | 84 | - [Willem Wigman][link-author] 85 | - [All Contributors][link-contributors] 86 | 87 | ## License 88 | 89 | The MIT License (MIT). Please see [License File](LICENSE.txt) for more information. 90 | 91 | [ico-version]: https://img.shields.io/packagist/v/integer-net/magento2-global-custom-layout.svg?style=flat-square 92 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 93 | [ico-travis]: https://img.shields.io/travis/integer-net/magento2-global-custom-layout/master.svg?style=flat-square 94 | [ico-scrutinizer]: https://scrutinizer-ci.com/g/integer-net/magento2-global-custom-layout/badges/coverage.png?b=master 95 | [ico-code-quality]: https://img.shields.io/scrutinizer/g/integer-net/magento2-global-custom-layout.svg?style=flat-square 96 | [ico-compatibility]: https://img.shields.io/badge/magento-%202.3%20|%202.4-brightgreen.svg?logo=magento&longCache=true&style=flat-square 97 | 98 | [link-packagist]: https://packagist.org/packages/integer-net/magento2-global-custom-layout 99 | [link-travis]: https://travis-ci.org/integer-net/magento2-global-custom-layout 100 | [link-scrutinizer]: https://scrutinizer-ci.com/g/integer-net/magento2-global-custom-layout/code-structure 101 | [link-code-quality]: https://scrutinizer-ci.com/g/integer-net/magento2-global-custom-layout 102 | [link-author]: https://github.com/wigman 103 | [link-contributors]: ../../contributors 104 | -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | grumphp: 2 | tasks: 3 | composer: [] 4 | phpcs: ~ 5 | phpstan: 6 | configuration: phpstan.neon 7 | phpparser: 8 | visitors: 9 | no_exit_statements: ~ 10 | forbidden_function_calls: 11 | blacklist: 12 | - 'var_dump' 13 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tests/ 4 | 5 | 6 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | - tests 6 | ignoreErrors: 7 | - '#(class|type) Magento\\TestFramework#i' 8 | - '#(class|type) Magento\\\S*Factory#i' 9 | - '#(method) Magento\\Framework\\Api\\ExtensionAttributesInterface#i' 10 | -------------------------------------------------------------------------------- /src/Plugin/CategoryLayoutPlugin.php: -------------------------------------------------------------------------------- 1 | themeFactory = $themeFactory; 50 | $this->design = $design; 51 | $this->layoutProcessorFactory = $layoutProcessorFactory; 52 | } 53 | 54 | /** 55 | * Get the processor instance. 56 | * 57 | * @return LayoutProcessor 58 | * 59 | * Unchanged private method copied over from @var LayoutUpdateManager 60 | */ 61 | private function getLayoutProcessor(): LayoutProcessor 62 | { 63 | if (!$this->layoutProcessor) { 64 | $this->layoutProcessor = $this->layoutProcessorFactory->create( 65 | [ 66 | 'theme' => $this->themeFactory->create( 67 | $this->design->getConfigurationDesignTheme(Area::AREA_FRONTEND) 68 | ), 69 | ] 70 | ); 71 | $this->themeFactory = null; 72 | $this->design = null; 73 | } 74 | 75 | return $this->layoutProcessor; 76 | } 77 | 78 | /** 79 | * Fetch list of available global files/handles for the category. 80 | * 81 | * @param LayoutUpdateManager $subject 82 | * @param array $result 83 | * @param CategoryInterface $category 84 | * @return array 85 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 86 | */ 87 | public function afterFetchAvailableFiles( 88 | LayoutUpdateManager $subject, 89 | array $result, 90 | CategoryInterface $category): array 91 | { 92 | $handles = $this->getLayoutProcessor()->getAvailableHandles(); 93 | return array_merge( 94 | $result, 95 | array_filter( 96 | array_map( 97 | function (string $handle): ?string { 98 | preg_match( 99 | '/^catalog\_category\_view\_selectable\_0\_([a-z0-9]+)/i', 100 | $handle, 101 | $selectable 102 | ); 103 | if (!empty($selectable[1])) { 104 | return $selectable[1]; 105 | } 106 | 107 | return null; 108 | }, 109 | $handles 110 | ) 111 | ) 112 | ); 113 | } 114 | 115 | /** 116 | * Extract selected global custom layout settings. 117 | * 118 | * If no update is selected none will apply. 119 | * 120 | * @param LayoutUpdateManager $subject 121 | * @param $result 122 | * @param CategoryInterface $category 123 | * @param DataObject $intoSettings 124 | * @return void 125 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 126 | */ 127 | public function afterExtractCustomSettings( 128 | LayoutUpdateManager $subject, 129 | $result, 130 | CategoryInterface $category, 131 | DataObject $intoSettings): void 132 | { 133 | if ($category->getId() && $value = $this->extractAttributeValue($category)) { 134 | $handles = $intoSettings->getPageLayoutHandles() ?? []; 135 | $handles = array_merge_recursive( 136 | $handles, 137 | ['selectable_0' => $value] 138 | ); 139 | $intoSettings->setPageLayoutHandles($handles); 140 | } 141 | } 142 | 143 | /** 144 | * Extract custom layout attribute value. 145 | * 146 | * @param CategoryInterface $category 147 | * @return mixed 148 | * 149 | * Unchanged private method copied over from @var LayoutUpdateManager 150 | */ 151 | private function extractAttributeValue(CategoryInterface $category) 152 | { 153 | if ($category instanceof Category && !$category->hasData(CategoryInterface::CUSTOM_ATTRIBUTES)) { 154 | return $category->getData('custom_layout_update_file'); 155 | } 156 | if ($attr = $category->getCustomAttribute('custom_layout_update_file')) { 157 | return $attr->getValue(); 158 | } 159 | 160 | return null; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Plugin/PageLayoutPlugin.php: -------------------------------------------------------------------------------- 1 | themeFactory = $themeFactory; 66 | $this->design = $design; 67 | $this->pageRepository = $pageRepository; 68 | $this->layoutProcessorFactory = $layoutProcessorFactory; 69 | $this->identityMap = $identityMap; 70 | } 71 | 72 | /** 73 | * Get the processor instance. 74 | * 75 | * @return LayoutProcessor 76 | * 77 | * Unchanged private method copied over from @var CustomLayoutManager 78 | */ 79 | private function getLayoutProcessor(): LayoutProcessor 80 | { 81 | if (!$this->layoutProcessor) { 82 | $this->layoutProcessor = $this->layoutProcessorFactory->create( 83 | [ 84 | 'theme' => $this->themeFactory->create( 85 | $this->design->getConfigurationDesignTheme(Area::AREA_FRONTEND) 86 | ) 87 | ] 88 | ); 89 | $this->themeFactory = null; 90 | $this->design = null; 91 | } 92 | 93 | return $this->layoutProcessor; 94 | } 95 | 96 | /** 97 | * Fetch list of available global files/handles for the page. 98 | * 99 | * @param CustomLayoutManagerInterface $subject 100 | * @param array $result 101 | * @param PageInterface $page 102 | * @return array 103 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 104 | */ 105 | public function afterFetchAvailableFiles( 106 | CustomLayoutManagerInterface $subject, 107 | array $result, 108 | PageInterface $page 109 | ): array { 110 | $handles = $this->getLayoutProcessor()->getAvailableHandles(); 111 | 112 | return array_merge($result, array_filter( 113 | array_map( 114 | function(string $handle) : ?string { 115 | preg_match( 116 | '/^cms\_page\_view\_selectable\_0\_([a-z0-9]+)/i', 117 | $handle, 118 | $selectable 119 | ); 120 | if (!empty($selectable[1])) { 121 | return $selectable[1]; 122 | } 123 | 124 | return null; 125 | }, 126 | $handles 127 | ) 128 | )); 129 | } 130 | 131 | /** 132 | * @param CustomLayoutManagerInterface $subject 133 | * @param $result 134 | * @param PageLayout $layout 135 | * @param CustomLayoutSelectedInterface $layoutSelected 136 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 137 | */ 138 | public function afterApplyUpdate( 139 | CustomLayoutManagerInterface $subject, 140 | $result, 141 | PageLayout $layout, 142 | CustomLayoutSelectedInterface $layoutSelected 143 | ): void { 144 | $layout->addPageLayoutHandles( 145 | ['selectable_0' => $layoutSelected->getLayoutFileId()] 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Plugin/ProductLayoutPlugin.php: -------------------------------------------------------------------------------- 1 | themeFactory = $themeFactory; 51 | $this->design = $design; 52 | $this->layoutProcessorFactory = $layoutProcessorFactory; 53 | } 54 | 55 | /** 56 | * Get the processor instance. 57 | * 58 | * @return LayoutProcessor 59 | * 60 | * Unchanged private method copied over from @var LayoutUpdateManager 61 | */ 62 | private function getLayoutProcessor(): LayoutProcessor 63 | { 64 | if (!$this->layoutProcessor) { 65 | $this->layoutProcessor = $this->layoutProcessorFactory->create( 66 | [ 67 | 'theme' => $this->themeFactory->create( 68 | $this->design->getConfigurationDesignTheme(Area::AREA_FRONTEND) 69 | ), 70 | ] 71 | ); 72 | $this->themeFactory = null; 73 | $this->design = null; 74 | } 75 | 76 | return $this->layoutProcessor; 77 | } 78 | 79 | /** 80 | * Fetch list of available global files/handles for the product. 81 | * 82 | * @param LayoutUpdateManager $subject 83 | * @param array $result 84 | * @param ProductInterface $product 85 | * @return array 86 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 87 | */ 88 | public function afterFetchAvailableFiles( 89 | LayoutUpdateManager $subject, 90 | array $result, 91 | ProductInterface $product): array 92 | { 93 | $handles = $this->getLayoutProcessor()->getAvailableHandles(); 94 | 95 | return array_merge( 96 | $result, 97 | array_filter( 98 | array_map( 99 | function (string $handle): ?string { 100 | preg_match( 101 | '/^catalog\_product\_view\_selectable\_0\_([a-z0-9]+)/i', 102 | $handle, 103 | $selectable 104 | ); 105 | if (!empty($selectable[1])) { 106 | return $selectable[1]; 107 | } 108 | 109 | return null; 110 | }, 111 | $handles 112 | ) 113 | ) 114 | ); 115 | } 116 | 117 | /** 118 | * Extract selected global custom layout settings. 119 | * 120 | * If no update is selected none will apply. 121 | * 122 | * @param LayoutUpdateManager $subject 123 | * @param $result 124 | * @param ProductInterface $product 125 | * @param DataObject $intoSettings 126 | * @return void 127 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 128 | */ 129 | public function afterExtractCustomSettings( 130 | LayoutUpdateManager $subject, 131 | $result, 132 | ProductInterface $product, 133 | DataObject $intoSettings): void 134 | { 135 | if ($product->getSku() && $value = $this->extractAttributeValue($product)) { 136 | $handles = $intoSettings->getPageLayoutHandles() ?? []; 137 | $handles = array_merge_recursive( 138 | $handles, 139 | ['selectable_0' => $value] 140 | ); 141 | $intoSettings->setPageLayoutHandles($handles); 142 | } 143 | } 144 | 145 | /** 146 | * Extract custom layout attribute value. 147 | * 148 | * @param ProductInterface $product 149 | * @return mixed 150 | * 151 | * Unchanged private method copied over from @var LayoutUpdateManager 152 | */ 153 | private function extractAttributeValue(ProductInterface $product) 154 | { 155 | if ($product instanceof Product && !$product->hasData(ProductInterface::CUSTOM_ATTRIBUTES)) { 156 | return $product->getData('custom_layout_update_file'); 157 | } 158 | if ($attr = $product->getCustomAttribute('custom_layout_update_file')) { 159 | return $attr->getValue(); 160 | } 161 | 162 | return null; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/registration.php: -------------------------------------------------------------------------------- 1 |