From a4ab39540d333730a53162717d3a8e59827b27aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 12:51:05 +0100 Subject: [PATCH 01/51] Add Element interface --- src/Elements/Element.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Elements/Element.php diff --git a/src/Elements/Element.php b/src/Elements/Element.php new file mode 100644 index 0000000..33cfcdf --- /dev/null +++ b/src/Elements/Element.php @@ -0,0 +1,25 @@ + Date: Sat, 19 Nov 2022 12:51:32 +0100 Subject: [PATCH 02/51] Add abstract element class --- src/Elements/AbstractElement.php | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/Elements/AbstractElement.php diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php new file mode 100644 index 0000000..4b766e1 --- /dev/null +++ b/src/Elements/AbstractElement.php @@ -0,0 +1,63 @@ +|string Array of properties in case the specific property is empty, property value if not. + * + * @throws \OutOfBoundsException In case attribute name is wrong or property doesn't exist. + */ + public function getAllowedAttributeData(string $attributeName, string $attributeProperty = '') + { + if (!isset($this->allowedAttributes[$attributeName])) { + throw new \OutOfBoundsException("Attribute {$attributeName} doesn't exist in the allowed attributes array."); + } + + if (empty($attributeProperty)) { + return $this->allowedAttributes[$attributeName]; + } + + if (!isset($this->allowedAttributes[$attributeName][$attributeProperty])) { + throw new \OutOfBoundsException("Property {$attributeProperty} doesn't exist in the {$attributeName} allowed attribute array."); + } + + return $this->allowedAttributes[$attributeName][$attributeProperty]; + } +} From 1f9355069a4af529fb5ac49905eadc655f144eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 12:52:09 +0100 Subject: [PATCH 03/51] Initial implementation of the mj-text element --- src/Elements/BodyComponents/MjText.php | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/Elements/BodyComponents/MjText.php diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php new file mode 100644 index 0000000..8ea3df7 --- /dev/null +++ b/src/Elements/BodyComponents/MjText.php @@ -0,0 +1,142 @@ +> + */ + protected array $allowedAttributes = [ + 'color' => [ + 'unit' => 'color', + 'description' => 'text color', + 'default_value' => '#000000', + ], + 'font-family' => [ + 'unit' => 'string', + 'description' => 'font', + 'default_value' => 'Ubuntu, Helvetica, Arial, sans-serif', + ], + 'font-size' => [ + 'unit' => 'px', + 'description' => 'text size', + 'default_value' => '13px', + ], + 'font-style' => [ + 'unit' => 'string', + 'description' => 'normal/italic/oblique', + 'default_value' => 'n/a', + ], + 'font-weight' => [ + 'unit' => 'number', + 'description' => 'text thickness', + 'default_value' => 'n/a', + ], + 'line-height' => [ + 'unit' => 'px', + 'description' => 'space between the lines', + 'default_value' => '1', + ], + 'letter-spacing' => [ + 'unit' => 'px,em', + 'description' => 'letter spacing', + 'default_value' => 'none', + ], + 'height' => [ + 'unit' => 'px', + 'description' => 'The height of the element', + 'default_value' => 'n/a', + ], + 'text-decoration' => [ + 'unit' => 'string', + 'description' => 'underline/overline/line-through/none', + 'default_value' => 'n/a', + ], + 'text-transform' => [ + 'unit' => 'string', + 'description' => 'uppercase/lowercase/capitalize', + 'default_value' => 'n/a', + ], + 'align' => [ + 'unit' => 'string', + 'description' => 'left/right/center/justify', + 'default_value' => 'left', + ], + 'container-background-color' => [ + 'unit' => 'color', + 'description' => 'inner element background color', + 'default_value' => 'n/a', + ], + 'padding' => [ + 'unit' => 'px', + 'description' => 'supports up to 4 parameters', + 'default_value' => '10px 25px', + ], + 'padding-top' => [ + 'unit' => 'px', + 'description' => 'top offset', + 'default_value' => 'n/a', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'description' => 'bottom offset', + 'default_value' => 'n/a', + ], + 'padding-left' => [ + 'unit' => 'px', + 'description' => 'left offset', + 'default_value' => 'n/a', + ], + 'padding-right' => [ + 'unit' => 'px', + 'description' => 'right offset', + 'default_value' => 'n/a', + ], + 'css-class' => [ + 'unit' => 'string', + 'description' => 'class name, added to the root HTML element created', + 'default_value' => 'n/a', + ], + ]; + + public function render(): string + { + return ''; + } + + public function renderContent(): string + { + return ''; + } + + public function getStyles(): string + { + return ''; + } +} From 57283633b9472b89a18c26fe3c69d00cb6b099f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 12:52:25 +0100 Subject: [PATCH 04/51] Update base test class --- tests/BaseTestCase.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index 16d50b0..bd0b6e3 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -2,6 +2,7 @@ namespace MadeByDenis\PhpMjmlRenderer\Tests; +use MadeByDenis\PhpMjmlRenderer\Elements\Element; use MadeByDenis\PhpMjmlRenderer\Parser\MjmlNode; use MadeByDenis\PhpMjmlRenderer\Parser\MjmlParser; use MadeByDenis\PhpMjmlRenderer\Renderer\MjmlRenderer; @@ -9,7 +10,8 @@ class BaseTestCase extends TestCase { - private MjmlRenderer $renderer; - private MjmlParser $parser; - private MjmlNode $node; + private ?MjmlRenderer $renderer; + private ?MjmlParser $parser; + private ?MjmlNode $node; + private ?Element $element; } From 4ea8626d73447eb7532ab08c80dff0165cb34f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 12:52:42 +0100 Subject: [PATCH 05/51] Add tests for the mj-text element class --- .../Elements/BodyComponents/MjTextTest.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/Unit/Elements/BodyComponents/MjTextTest.php diff --git a/tests/Unit/Elements/BodyComponents/MjTextTest.php b/tests/Unit/Elements/BodyComponents/MjTextTest.php new file mode 100644 index 0000000..5b922a8 --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjTextTest.php @@ -0,0 +1,32 @@ +element = new MjText(); +}); + +it('is ending tag', function () { + expect($this->element->isEndingTag())->toBeTrue(); +}); + +it('returns the correct component name', function () { + expect($this->element->getComponentName())->toBe('mj-text'); +}); + +it('returns the correct default attribute', function () { + expect($this->element->getAllowedAttributeData('color')) + ->toBeArray() + ->and($this->element->getAllowedAttributeData('color')['default_value']) + ->toBe('#000000'); +}); + +it('will throw out of bounds exception if the allowed attribute is not existing', function () { + $this->element->getAllowedAttributeData('colour'); +})->expectException(\OutOfBoundsException::class); + +it('will throw out of bounds exception if the allowed attribute property is not existing', function () { + $this->element->getAllowedAttributeData('colour')['name']; +})->expectException(\OutOfBoundsException::class); From 312100f3e3af3854097e3ee3c0828cca4ea923e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 16:33:34 +0100 Subject: [PATCH 06/51] Fix the phpcs issue in the abstract element class --- src/Elements/AbstractElement.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index 4b766e1..23d1d9b 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -47,7 +47,9 @@ public function getComponentName(): string public function getAllowedAttributeData(string $attributeName, string $attributeProperty = '') { if (!isset($this->allowedAttributes[$attributeName])) { - throw new \OutOfBoundsException("Attribute {$attributeName} doesn't exist in the allowed attributes array."); + throw new \OutOfBoundsException( + "Attribute {$attributeName} doesn't exist in the allowed attributes array." + ); } if (empty($attributeProperty)) { @@ -55,7 +57,9 @@ public function getAllowedAttributeData(string $attributeName, string $attribute } if (!isset($this->allowedAttributes[$attributeName][$attributeProperty])) { - throw new \OutOfBoundsException("Property {$attributeProperty} doesn't exist in the {$attributeName} allowed attribute array."); + throw new \OutOfBoundsException( + "Property {$attributeProperty} doesn't exist in the {$attributeName} allowed attribute array." + ); } return $this->allowedAttributes[$attributeName][$attributeProperty]; From 7a3cdd695955e4d87a96bc5dca7cedb9c8280c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 16:35:30 +0100 Subject: [PATCH 07/51] Add cleanup for test properties --- tests/Pest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Pest.php b/tests/Pest.php index a810553..87b3211 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,6 +6,13 @@ uses(BaseTestCase::class)->in('Unit'); +afterEach(function() { + $this->renderer = null; + $this->parser = null; + $this->node = null; + $this->element = null; +}); + function expandDebugLog() { ini_set("xdebug.var_display_max_children", '-1'); ini_set("xdebug.var_display_max_data", '-1'); From 22a9ffec87dae7bc46813a6c5a3f06df02b358a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sat, 19 Nov 2022 16:45:25 +0100 Subject: [PATCH 08/51] Reorder allowed attributes in alphabetical order --- src/Elements/BodyComponents/MjText.php | 66 +++++++++++++------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php index 8ea3df7..463ec5e 100644 --- a/src/Elements/BodyComponents/MjText.php +++ b/src/Elements/BodyComponents/MjText.php @@ -33,11 +33,26 @@ class MjText extends AbstractElement * @var array> */ protected array $allowedAttributes = [ + 'align' => [ + 'unit' => 'string', + 'description' => 'left/right/center/justify', + 'default_value' => 'left', + ], 'color' => [ 'unit' => 'color', 'description' => 'text color', 'default_value' => '#000000', ], + 'container-background-color' => [ + 'unit' => 'color', + 'description' => 'inner element background color', + 'default_value' => 'n/a', + ], + 'css-class' => [ + 'unit' => 'string', + 'description' => 'class name, added to the root HTML element created', + 'default_value' => 'n/a', + ], 'font-family' => [ 'unit' => 'string', 'description' => 'font', @@ -58,51 +73,26 @@ class MjText extends AbstractElement 'description' => 'text thickness', 'default_value' => 'n/a', ], - 'line-height' => [ + 'height' => [ 'unit' => 'px', - 'description' => 'space between the lines', - 'default_value' => '1', + 'description' => 'The height of the element', + 'default_value' => 'n/a', ], 'letter-spacing' => [ 'unit' => 'px,em', 'description' => 'letter spacing', 'default_value' => 'none', ], - 'height' => [ + 'line-height' => [ 'unit' => 'px', - 'description' => 'The height of the element', - 'default_value' => 'n/a', - ], - 'text-decoration' => [ - 'unit' => 'string', - 'description' => 'underline/overline/line-through/none', - 'default_value' => 'n/a', - ], - 'text-transform' => [ - 'unit' => 'string', - 'description' => 'uppercase/lowercase/capitalize', - 'default_value' => 'n/a', - ], - 'align' => [ - 'unit' => 'string', - 'description' => 'left/right/center/justify', - 'default_value' => 'left', - ], - 'container-background-color' => [ - 'unit' => 'color', - 'description' => 'inner element background color', - 'default_value' => 'n/a', + 'description' => 'space between the lines', + 'default_value' => '1', ], 'padding' => [ 'unit' => 'px', 'description' => 'supports up to 4 parameters', 'default_value' => '10px 25px', ], - 'padding-top' => [ - 'unit' => 'px', - 'description' => 'top offset', - 'default_value' => 'n/a', - ], 'padding-bottom' => [ 'unit' => 'px', 'description' => 'bottom offset', @@ -118,9 +108,19 @@ class MjText extends AbstractElement 'description' => 'right offset', 'default_value' => 'n/a', ], - 'css-class' => [ + 'padding-top' => [ + 'unit' => 'px', + 'description' => 'top offset', + 'default_value' => 'n/a', + ], + 'text-decoration' => [ 'unit' => 'string', - 'description' => 'class name, added to the root HTML element created', + 'description' => 'underline/overline/line-through/none', + 'default_value' => 'n/a', + ], + 'text-transform' => [ + 'unit' => 'string', + 'description' => 'uppercase/lowercase/capitalize', 'default_value' => 'n/a', ], ]; From d97708889eb0b3961a1b5b0ab28fea1b6f641674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 09:59:04 +0100 Subject: [PATCH 09/51] Remove gitkeep --- src/Elements/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/Elements/.gitkeep diff --git a/src/Elements/.gitkeep b/src/Elements/.gitkeep deleted file mode 100644 index e69de29..0000000 From 691a20e580c05cc81f63aad130d1b7727f0e1cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 09:59:52 +0100 Subject: [PATCH 10/51] Add tests for the parser, renderer and some elements Still a WIP and the tests will fail. Need to work on this when I find the time. --- .../Elements/BodyComponents/MjTextTest.php | 2 +- tests/Unit/Elements/ElementFactoryTest.php | 27 ++ tests/Unit/Parser/NodeTest.php | 2 - tests/Unit/Parser/ParserTest.php | 231 +++++++++--------- tests/Unit/Renderer/RenderTest.php | 2 +- 5 files changed, 144 insertions(+), 120 deletions(-) create mode 100644 tests/Unit/Elements/ElementFactoryTest.php diff --git a/tests/Unit/Elements/BodyComponents/MjTextTest.php b/tests/Unit/Elements/BodyComponents/MjTextTest.php index 5b922a8..d08111d 100644 --- a/tests/Unit/Elements/BodyComponents/MjTextTest.php +++ b/tests/Unit/Elements/BodyComponents/MjTextTest.php @@ -13,7 +13,7 @@ }); it('returns the correct component name', function () { - expect($this->element->getComponentName())->toBe('mj-text'); + expect($this->element->getTagName())->toBe('mj-text'); }); it('returns the correct default attribute', function () { diff --git a/tests/Unit/Elements/ElementFactoryTest.php b/tests/Unit/Elements/ElementFactoryTest.php new file mode 100644 index 0000000..6216a68 --- /dev/null +++ b/tests/Unit/Elements/ElementFactoryTest.php @@ -0,0 +1,27 @@ +factory = new ElementFactory(); +}); + +it('Will correctly return class of the desired element', function () { + $textNode = new MjmlNode( + 'mj-text', + [ + 'mj-class' => 'blue big' + ], + 'Hello World!', + false, + null, + ); + + $mjTextElement = $this->factory->create($textNode); + + expect($mjTextElement)->toBeInstanceOf(MjText::class); +}); diff --git a/tests/Unit/Parser/NodeTest.php b/tests/Unit/Parser/NodeTest.php index 2f92643..97804a9 100644 --- a/tests/Unit/Parser/NodeTest.php +++ b/tests/Unit/Parser/NodeTest.php @@ -17,8 +17,6 @@ ); }); -afterEach(fn() => $this->node = null); - it('will return the tag', function() { expect($this->node->getTag())->toBe('mj-text'); }); diff --git a/tests/Unit/Parser/ParserTest.php b/tests/Unit/Parser/ParserTest.php index e8ca890..6de80b6 100644 --- a/tests/Unit/Parser/ParserTest.php +++ b/tests/Unit/Parser/ParserTest.php @@ -2,6 +2,7 @@ namespace MadeByDenis\PhpMjmlRenderer\Tests\Unit\Parser; +use MadeByDenis\PhpMjmlRenderer\Node; use MadeByDenis\PhpMjmlRenderer\Parser\MjmlNode; use MadeByDenis\PhpMjmlRenderer\ParserFactory; @@ -9,8 +10,6 @@ $this->parser = ParserFactory::create(); }); -afterEach(fn() => $this->parser = null); - it('parses single element', function() { $mjml = <<<'MJML' @@ -19,18 +18,18 @@ MJML; $parsedContent = $this->parser->parse($mjml); - expect($parsedContent)->toEqualCanonicalizing( - [ - new MjmlNode( - 'mj-text', - [ - 'mj-class' => 'blue big', - ], - 'Hello World!', - false, - null - ), - ] + expect($parsedContent) + ->toBeInstanceOf(Node::class) + ->toEqualCanonicalizing( + new MjmlNode( + 'mj-text', + [ + 'mj-class' => 'blue big', + ], + 'Hello World!', + false, + null + ) ); }); @@ -58,114 +57,114 @@ MJML; $parsedContent = $this->parser->parse($mjml); - expect($parsedContent)->toEqualCanonicalizing( - [ - new MjmlNode( - 'mjml', - null, - null, - false, - [ - new MjmlNode( - 'mj-head', - [ - 'background-color' => '#FFF', - ], - null, - false, - [ - new MjmlNode( - 'mj-attributes', - null, - null, - false, - [ - new MjmlNode( - 'mj-text', - [ - 'padding' => '0', - ], - null, - true, - null - ), - new MjmlNode( - 'mj-class', - [ - 'name' => 'blue', - 'color' => 'blue', - ], - null, - true, - null - ), - new MjmlNode( - 'mj-class', - [ - 'name' => 'big', - 'font-size' => '20px', - ], - null, - true, - null - ), - new MjmlNode( - 'mj-all', - [ - 'font-family' => 'Arial', - ], - null, - true, - null - ), - ] - ), - ] - ), - new MjmlNode( - 'mj-body', - null, - null, - false, - [ - new MjmlNode( - 'mj-section', - null, - null, - false, - [ - new MjmlNode( - 'mj-column', - null, - null, - false, - [ - new MjmlNode( - 'mj-text', - [ - 'mj-class' => 'blue big' - ], - 'Hello World!', - false, - null, - ), - ] - ), - ] - ), - ] - ), - ] - ), - ] + expect($parsedContent) + ->toBeInstanceOf(Node::class) + ->toEqualCanonicalizing( + new MjmlNode( + 'mjml', + null, + null, + false, + [ + new MjmlNode( + 'mj-head', + [ + 'background-color' => '#FFF', + ], + null, + false, + [ + new MjmlNode( + 'mj-attributes', + null, + null, + false, + [ + new MjmlNode( + 'mj-text', + [ + 'padding' => '0', + ], + null, + true, + null + ), + new MjmlNode( + 'mj-class', + [ + 'name' => 'blue', + 'color' => 'blue', + ], + null, + true, + null + ), + new MjmlNode( + 'mj-class', + [ + 'name' => 'big', + 'font-size' => '20px', + ], + null, + true, + null + ), + new MjmlNode( + 'mj-all', + [ + 'font-family' => 'Arial', + ], + null, + true, + null + ), + ] + ), + ] + ), + new MjmlNode( + 'mj-body', + null, + null, + false, + [ + new MjmlNode( + 'mj-section', + null, + null, + false, + [ + new MjmlNode( + 'mj-column', + null, + null, + false, + [ + new MjmlNode( + 'mj-text', + [ + 'mj-class' => 'blue big' + ], + 'Hello World!', + false, + null, + ), + ] + ), + ] + ), + ] + ), + ] + ) ); }); -it('thorows error on malformed MJML code', function () { +it('throws error on malformed MJML code', function () { $mjml = <<<'MJML' MJML; - $parsedContent = $this->parser->parse($mjml); + $this->parser->parse($mjml); })->expectExceptionMessage('simplexml_load_string(): Entity:'); diff --git a/tests/Unit/Renderer/RenderTest.php b/tests/Unit/Renderer/RenderTest.php index 9d91fec..dca5444 100644 --- a/tests/Unit/Renderer/RenderTest.php +++ b/tests/Unit/Renderer/RenderTest.php @@ -47,4 +47,4 @@ $htmlOut = $this->renderer->render($mjml); expect($htmlOut)->toEqual($htmlExpected); -})->skip(); +}); From 3a1207a37ffaf8d173e0ac6d3ab561375e2692b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:00:46 +0100 Subject: [PATCH 11/51] Change parser to return a node instead of array of nodes --- src/Parser.php | 4 ++-- src/Parser/MjmlNode.php | 7 ++++++- src/Parser/MjmlParser.php | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Parser.php b/src/Parser.php index 5132f24..a42a53d 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -24,7 +24,7 @@ interface Parser * * @param string $sourceCode The MJML source code to parse. * - * @return Node[] Array of node objects with some details about the elements. + * @return Node Node object with details about the elements. */ - public function parse(string $sourceCode); + public function parse(string $sourceCode): Node; } diff --git a/src/Parser/MjmlNode.php b/src/Parser/MjmlNode.php index d82458d..186decf 100644 --- a/src/Parser/MjmlNode.php +++ b/src/Parser/MjmlNode.php @@ -98,6 +98,11 @@ public function getChildren(): ?array */ public function setChildren(?array $childNodes): void { - $this->children = $childNodes; + $this->children = $childNodes; + } + + public function hasChildren(): bool + { + return !empty($this->children); } } diff --git a/src/Parser/MjmlParser.php b/src/Parser/MjmlParser.php index 9665941..e841357 100644 --- a/src/Parser/MjmlParser.php +++ b/src/Parser/MjmlParser.php @@ -24,7 +24,7 @@ */ final class MjmlParser implements Parser { - public function parse(string $sourceCode) + public function parse(string $sourceCode): Node { // Parse the code. try { @@ -86,7 +86,7 @@ public function parse(string $sourceCode) return $parentNode; }; - return [$parser($simpleXmlElement)]; + return $parser($simpleXmlElement); } private function parseSingleElement(\SimpleXMLElement $element): Node @@ -99,7 +99,7 @@ private function parseSingleElement(\SimpleXMLElement $element): Node $value = null; } else { $isSelfClosing = false; - $value = trim((string) $element); // should we trim? + $value = trim((string)$element); // should we trim? } return new MjmlNode( From aff8b159c0a9af6e68a6caeca2ed4acf83a92039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:01:02 +0100 Subject: [PATCH 12/51] Add renderer code --- src/Renderer/MjmlRenderer.php | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Renderer/MjmlRenderer.php b/src/Renderer/MjmlRenderer.php index 6529910..e6f21b7 100644 --- a/src/Renderer/MjmlRenderer.php +++ b/src/Renderer/MjmlRenderer.php @@ -12,6 +12,8 @@ namespace MadeByDenis\PhpMjmlRenderer\Renderer; +use MadeByDenis\PhpMjmlRenderer\Elements\ElementFactory; +use MadeByDenis\PhpMjmlRenderer\ParserFactory; use MadeByDenis\PhpMjmlRenderer\Renderer; /** @@ -29,7 +31,28 @@ class MjmlRenderer implements Renderer public function render(string $content): string { // Parse content. - // Render content based on nodes. - return $content; + $parser = ParserFactory::create(); + + $parsedContent = $parser->parse($content); + + $contentRender = function ($nodeElement, $content) use (&$contentRender) { + if (!$nodeElement->hasChildren()) { + $content .= ElementFactory::create($nodeElement)->render(); + + return $content; + } + + foreach ($nodeElement->getChildren() as $childNode) { + if ($childNode->hasChildren()) { + $contentRender($childNode, $content); + } else { + $content .= ElementFactory::create($childNode)->render(); + } + } + + return $content; + }; + + return $contentRender($parsedContent, ''); } } From b42bc18f0d923e1987baa9b4c7d76de040961b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:02:20 +0100 Subject: [PATCH 13/51] Add conditional tag helper trait --- src/Elements/Helpers/ConditionalTag.php | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/Elements/Helpers/ConditionalTag.php diff --git a/src/Elements/Helpers/ConditionalTag.php b/src/Elements/Helpers/ConditionalTag.php new file mode 100644 index 0000000..49daecb --- /dev/null +++ b/src/Elements/Helpers/ConditionalTag.php @@ -0,0 +1,39 @@ +'; + protected string $startMsoConditionalTag = ''; + protected string $startNegationConditionalTag = ''; + protected string $startMsoNegationConditionalTag = ''; + protected string $endNegationConditionalTag = ''; + + protected function conditionalTag(string $content, bool $negation = false): string + { + $tagStart = $negation ? $this->startNegationConditionalTag : $this->startConditionalTag; + $tagEnd = $negation ? $this->endNegationConditionalTag : $this->endConditionalTag; + + return "$tagStart $content $tagEnd"; + } + + protected function msoConditionalTag(string $content, bool $negation = false): string + { + $tagStart = $negation ? $this->startMsoNegationConditionalTag : $this->startMsoConditionalTag; + $tagEnd = $negation ? $this->endNegationConditionalTag : $this->endConditionalTag; + + return "$tagStart $content $tagEnd"; + } +} From 03fb6f9772f1559a97b1215a4ddb7221933a620b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:03:35 +0100 Subject: [PATCH 14/51] Add element factory Factory should be used to quickly generate elements. Still a WIP, as I need to finish working on the text element, then start working on the others to see if this can be reused or not. --- src/Elements/ElementFactory.php | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/Elements/ElementFactory.php diff --git a/src/Elements/ElementFactory.php b/src/Elements/ElementFactory.php new file mode 100644 index 0000000..6dba53f --- /dev/null +++ b/src/Elements/ElementFactory.php @@ -0,0 +1,76 @@ +getTag(); + $class = self::getTagClass($tag); + + return new $class; // phpcs:ignore PSR12.Classes.ClassInstantiation.MissingParentheses + } + + private static function getTagClass(string $tag): string + { + static $classNames = []; + + if (empty($classNames)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__)); + $allFiles = array_filter(iterator_to_array($iterator), fn($file) => $file->isFile()); + + $classNames = []; + + foreach ($allFiles as $fileInfo) { + if (in_array($fileInfo->getFilename(), self::EXCLUDED_FILES, true)) { + continue; + } + + // We can do this, because we are using PSR-4 convention. + $classFQN = 'MadeByDenis\\PhpMjmlRenderer\\Elements' . self::getElementClass( + __DIR__, + $fileInfo->getPathName() + ); + $classNames[$classFQN::TAG_NAME] = $classFQN; + } + } + + return $classNames[$tag]; + } + + private static function getElementClass(string $dir, string $path): string + { + $namespacedPath = str_replace($dir, '', $path); + $elementClass = str_replace('.php', '', $namespacedPath); + + return str_replace('/', '\\', $elementClass); + } +} From 1e2112876a0edd5650173f988feaa31e18116dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:05:21 +0100 Subject: [PATCH 15/51] Add abstract element class and the element class See what parts we can abstract away for the prototype pattern. --- src/Elements/AbstractElement.php | 111 ++++++++++++++++++++++++++++++- src/Elements/Element.php | 2 +- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index 23d1d9b..daf7147 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -21,17 +21,43 @@ */ abstract class AbstractElement implements Element { - public const COMPONENT_NAME = ''; + public const TAG_NAME = ''; public const ENDING_TAG = false; + protected bool $rawElement = false; + protected array $defaultAttributes = []; + protected array $allowedAttributes = []; + private array $attributes = []; + private array $children = []; + private array $properties = []; + private array $globalAttributes = []; + private string $context = ''; + private string $content = ''; + private ?string $absoluteFilePath = null; + + public function __construct() + { + $this->attributes = $this->formatAttributes( + $this->defaultAttributes, + $this->allowedAttributes, + ); + + return $this; + } + public function isEndingTag(): bool { return static::ENDING_TAG; } - public function getComponentName(): string + public function getTagName(): string { - return static::COMPONENT_NAME; + return static::TAG_NAME; + } + + public function isRawElement(): bool + { + return $this->rawElement; } /** @@ -64,4 +90,83 @@ public function getAllowedAttributeData(string $attributeName, string $attribute return $this->allowedAttributes[$attributeName][$attributeProperty]; } + + public function getChildContext(): string + { + return $this->context; + } + + public function getAttribute(string $attributeName) + { + return $this->attributes[$attributeName]; + } + + protected function getContent(): string + { + return trim($this->content); + } + + protected function getHtmlAttributes(array $attributes): string + { + $specialAttributes = [ + 'style' => fn($style) => $this->styles($style), + 'default' => $this->defaultAttributes, + ]; + + $nonEmpty = array_filter($attributes, fn($element) => !empty($element)); + + array_walk($nonEmpty, function ($val, $key) use (&$attrOut, $specialAttributes) { + $value = (!empty($specialAttributes[$key]) ? + $specialAttributes[$key] : + $specialAttributes['default'])[$val]; + + $attrOut .= " $key=\"$value\""; + }); + + return $attrOut; + } + + abstract public function getStyles(): array; + + protected function styles($styles) + { + $stylesArray = []; + + if (!empty($styles)) { + if (is_string($styles)) { + $stylesArray = $this->getStyles()[$styles]; + } else { + $stylesArray = $styles; + } + } + + array_walk($stylesArray, function ($val, $key) use (&$styles) { + if (!empty($val)) { + $styles .= " $key=\"$val\""; + } + }); + + return $styles; + } + + private function formatAttributes(array $attributes, array $allowedAttributes): array + { + /* + * Check if the attributes are of the proper format based on the allowed attributes. + * For instance, if you pass a non string value to the 'align' attribute, you should get an error. + * Otherwise you'd get an array of attributes like: + * + * [ + * 'background-repeat' => 'repeat', + * 'background-size' => 'auto', + * 'background-position' => 'top center', + * 'direction' => 'ltr', + * 'padding' => '20px 0', + * 'text-align' => 'center', + * 'text-padding' => '4px 4px 4px 0' + * ] + */ + + return []; + } } diff --git a/src/Elements/Element.php b/src/Elements/Element.php index 33cfcdf..b3a37c1 100644 --- a/src/Elements/Element.php +++ b/src/Elements/Element.php @@ -21,5 +21,5 @@ interface Element { public function render(): string; public function renderContent(): string; - public function getStyles(): string; + public function getStyles(): array; } From d8039d04a3077294148b0847941c627dbf95390a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Fri, 1 Dec 2023 10:05:48 +0100 Subject: [PATCH 16/51] WIP text element --- src/Elements/BodyComponents/MjText.php | 51 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php index 463ec5e..f7565f9 100644 --- a/src/Elements/BodyComponents/MjText.php +++ b/src/Elements/BodyComponents/MjText.php @@ -13,6 +13,7 @@ namespace MadeByDenis\PhpMjmlRenderer\Elements\BodyComponents; use MadeByDenis\PhpMjmlRenderer\Elements\AbstractElement; +use MadeByDenis\PhpMjmlRenderer\Elements\Helpers\ConditionalTag; /** * Mjml Text Element @@ -23,7 +24,9 @@ */ class MjText extends AbstractElement { - public const COMPONENT_NAME = 'mj-text'; + use ConditionalTag; + + public const TAG_NAME = 'mj-text'; public const ENDING_TAG = true; @@ -125,18 +128,56 @@ class MjText extends AbstractElement ], ]; + protected array $defaultAttributes = [ + 'align' => 'left', + 'color' => '#000000', + 'font-family' => 'Ubuntu, Helvetica, Arial, sans-serif', + 'font-size' => '13px', + 'line-height' => '1', + 'padding' => '10px 25px', + ]; + public function render(): string { - return ''; + $height = $this->getAttribute('height'); + $conditionalTagStart = $this->conditionalTag( + "
" // phpcs:ignore Generic.Files.LineLength.TooLong + ); + + $conditionalTagEnd = $this->conditionalTag('
'); + + return $height ? + $conditionalTagStart . $this->renderContent() . $conditionalTagEnd : + $this->renderContent(); } public function renderContent(): string { - return ''; + $htmlAttributes = $this->getHtmlAttributes([ + 'style' => 'text', + ]); + + $content = $this->getContent(); + + return "
$content
"; } - public function getStyles(): string + public function getStyles(): array { - return ''; + return [ + 'text' => [ + 'font-family' => $this->getAttribute('font-family'), + 'font-size' => $this->getAttribute('font-size'), + 'font-style' => $this->getAttribute('font-style'), + 'font-weight' => $this->getAttribute('font-weight'), + 'letter-spacing' => $this->getAttribute('letter-spacing'), + 'line-height' => $this->getAttribute('line-height'), + 'text-align' => $this->getAttribute('align'), + 'text-decoration' => $this->getAttribute('text-decoration'), + 'text-transform' => $this->getAttribute('text-transform'), + 'color' => $this->getAttribute('color'), + 'height' => $this->getAttribute('height'), + ] + ]; } } From 1084974a9e639eea654d24a82b6e723c69efd859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:24:58 +0100 Subject: [PATCH 17/51] Update dependencies --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 992065b..a0fe631 100644 --- a/composer.json +++ b/composer.json @@ -25,12 +25,12 @@ "ext-simplexml": "*" }, "require-dev": { - "squizlabs/php_codesniffer": "^3.7", + "captainhook/captainhook": "^5.11", "pestphp/pest": "^1.22", - "phpstan/phpstan": "^1.9", + "php-parallel-lint/php-parallel-lint": "^1.3", "phpcompatibility/php-compatibility": "^9.3", - "captainhook/captainhook": "^5.11", - "php-parallel-lint/php-parallel-lint": "^1.3" + "phpcsstandards/php_codesniffer": "^3.7", + "phpstan/phpstan": "^1.9" }, "autoload": { "psr-4": { From 6e4b960eb2ac289714b7907e543caa6160d1e8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:25:42 +0100 Subject: [PATCH 18/51] Improve factory create method Exclude directories from the list. --- src/Elements/ElementFactory.php | 41 +++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Elements/ElementFactory.php b/src/Elements/ElementFactory.php index 6dba53f..89eb504 100644 --- a/src/Elements/ElementFactory.php +++ b/src/Elements/ElementFactory.php @@ -19,8 +19,6 @@ final class ElementFactory { private const EXCLUDED_FILES = [ - '.', - '..', 'Helpers', 'AbstractElement.php', 'Element.php', @@ -35,8 +33,10 @@ public static function create(Node $node): Element $tag = self::$node->getTag(); $class = self::getTagClass($tag); + $attributes = $node->getAttributes(); + $content = $node->getInnerContent(); - return new $class; // phpcs:ignore PSR12.Classes.ClassInstantiation.MissingParentheses + return new $class($attributes, $content); // phpcs:ignore PSR12.Classes.ClassInstantiation.MissingParentheses } private static function getTagClass(string $tag): string @@ -50,15 +50,16 @@ private static function getTagClass(string $tag): string $classNames = []; foreach ($allFiles as $fileInfo) { - if (in_array($fileInfo->getFilename(), self::EXCLUDED_FILES, true)) { + // Skip excluded files. Also, skip entire excluded directories. + if (self::strposa($fileInfo->getPathname(), self::EXCLUDED_FILES) !== false) { continue; } // We can do this, because we are using PSR-4 convention. $classFQN = 'MadeByDenis\\PhpMjmlRenderer\\Elements' . self::getElementClass( - __DIR__, - $fileInfo->getPathName() - ); + __DIR__, + $fileInfo->getPathName() + ); $classNames[$classFQN::TAG_NAME] = $classFQN; } } @@ -73,4 +74,30 @@ private static function getElementClass(string $dir, string $path): string return str_replace('/', '\\', $elementClass); } + + /** + * Strpos for array of strings + * + * Slightly modified version of the function found on StackOverflow. + * @link https://stackoverflow.com/a/9220624/629127 + * + * @param string $haystack + * @param String[] $needles + * @param int $offset + * + * @return bool + */ + private static function strposa(string $haystack, array $needles, int $offset = 0): bool + { + $inside = false; + + foreach ($needles as $needle) { + if (strpos($haystack, $needle, $offset) !== false) { + $inside = true; + break; + } + } + + return $inside; + } } From 16bdc380f593eb53a34daa2ad9de9da13a3ecd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:26:42 +0100 Subject: [PATCH 19/51] Add type validator helper Will be used to check passed attributes, and will probably need to have a lot more validators for css values. For instance alignment, etc. --- src/Elements/Helpers/TypeValidator.php | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/Elements/Helpers/TypeValidator.php diff --git a/src/Elements/Helpers/TypeValidator.php b/src/Elements/Helpers/TypeValidator.php new file mode 100644 index 0000000..e043810 --- /dev/null +++ b/src/Elements/Helpers/TypeValidator.php @@ -0,0 +1,94 @@ +namedColors[$color])) { + return true; + } + + // Check if the color is in valid HWB format. + if (preg_match('/^hwb\(\d+,\s*\d+%?,\s*\d+%?\)$/', $color)) { + return true; + } + + // Check if the color is in valid LAB format. + if (preg_match('/^lab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid LCH format. + if (preg_match('/^lch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid Oklab format. + if (preg_match('/^oklab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid Oklch format. + if (preg_match('/^oklch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid light-dark format. + if (in_array(strtolower($color), ['light', 'dark'])) { + return true; + } + + // If none of the formats match, return false. + return false; + } + + public function isValidMeasure(string $measure): bool + { + // Regular expression pattern for a valid measure (number followed by the unit without whitespace). + $pattern = '/^\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i'; + + return preg_match($pattern, $measure) === 1; + } + + public function isNumber(string $number): bool + { + return is_numeric($number); + } + + public function isInteger(string $number): bool + { + return is_numeric($number) && (int) $number == $number; + } +} From 4d0d5796f97be62e5d73290c0026865ef7f29007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:27:22 +0100 Subject: [PATCH 20/51] Allow creation of dynamic properties They are needed to create on the fly entities in the test. --- tests/BaseTestCase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index bd0b6e3..23bcf0e 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -8,6 +8,7 @@ use MadeByDenis\PhpMjmlRenderer\Renderer\MjmlRenderer; use PHPUnit\Framework\TestCase; +#[AllowDynamicProperties] class BaseTestCase extends TestCase { private ?MjmlRenderer $renderer; From b65abf82ed07a67593a2c27548d547c859f18f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:28:01 +0100 Subject: [PATCH 21/51] Add additional test for the render method They will currently fail, because I need to add column, section, body and mjml elements. --- tests/Unit/Renderer/RenderTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Unit/Renderer/RenderTest.php b/tests/Unit/Renderer/RenderTest.php index dca5444..f5212f8 100644 --- a/tests/Unit/Renderer/RenderTest.php +++ b/tests/Unit/Renderer/RenderTest.php @@ -48,3 +48,31 @@ expect($htmlOut)->toEqual($htmlExpected); }); + +it('renders the MJML to correct HTML version with attributes', function () { + $mjml = <<<'MJML' + + + + + + + + + + Hello World! + + + + + +MJML; + + $htmlExpected = <<<'HTML' + +HTML; + + $htmlOut = $this->renderer->render($mjml); + + expect($htmlOut)->toEqual($htmlExpected); +}); From a30ff7d220e76bb711885c8dcd3fb1009c86bbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:55:18 +0100 Subject: [PATCH 22/51] WIP Modify the getHtmlAttributes and formatAttributes methods Still need to write the code when the arguments are passed for the element. --- src/Elements/AbstractElement.php | 120 +++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 23 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index daf7147..c493f51 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -25,24 +25,41 @@ abstract class AbstractElement implements Element public const ENDING_TAG = false; protected bool $rawElement = false; + + /** + * @var array + */ protected array $defaultAttributes = []; + + /** + * @var array> + */ protected array $allowedAttributes = []; - private array $attributes = []; - private array $children = []; - private array $properties = []; - private array $globalAttributes = []; - private string $context = ''; - private string $content = ''; - private ?string $absoluteFilePath = null; - - public function __construct() + + /** + * @var array + */ + protected array $attributes = []; + + protected array $children = []; + + protected array $properties = []; + + protected array $globalAttributes = []; + + protected string $context = ''; + protected string $content = ''; + protected ?string $absoluteFilePath = null; + + public function __construct(?array $attributes = [], ?string $content = null) { $this->attributes = $this->formatAttributes( $this->defaultAttributes, $this->allowedAttributes, + $attributes, ); - return $this; + $this->content = $content; } public function isEndingTag(): bool @@ -96,9 +113,13 @@ public function getChildContext(): string return $this->context; } + /** + * @param string $attributeName + * @return mixed|null + */ public function getAttribute(string $attributeName) { - return $this->attributes[$attributeName]; + return $this->attributes[$attributeName] ?? null; } protected function getContent(): string @@ -106,29 +127,40 @@ protected function getContent(): string return trim($this->content); } - protected function getHtmlAttributes(array $attributes): string + /** + * @param array $attributes + * + * @return string|null + */ + protected function getHtmlAttributes(array $attributes): ?string { + // $style is fetched from the $attributes array. + // If it's not empty, it's passed to the $this->styles() method. + $style = $attributes['style'] ?? ''; + $specialAttributes = [ - 'style' => fn($style) => $this->styles($style), + 'style' => $this->styles($style), 'default' => $this->defaultAttributes, ]; $nonEmpty = array_filter($attributes, fn($element) => !empty($element)); + $attrOut = ''; + array_walk($nonEmpty, function ($val, $key) use (&$attrOut, $specialAttributes) { - $value = (!empty($specialAttributes[$key]) ? + $value = !empty($specialAttributes[$key]) ? $specialAttributes[$key] : - $specialAttributes['default'])[$val]; + $specialAttributes['default']; - $attrOut .= " $key=\"$value\""; + $attrOut .= "$key=\"$value\""; }); - return $attrOut; + return trim($attrOut); } abstract public function getStyles(): array; - protected function styles($styles) + protected function styles($styles): string { $stylesArray = []; @@ -140,16 +172,18 @@ protected function styles($styles) } } + $styles = ''; + array_walk($stylesArray, function ($val, $key) use (&$styles) { if (!empty($val)) { - $styles .= " $key=\"$val\""; + $styles .= "$key:$val;"; } }); - return $styles; + return trim($styles); } - private function formatAttributes(array $attributes, array $allowedAttributes): array + private function formatAttributes(array $defaultAttributes, array $allowedAttributes, ?array $passedAttributes = []): array { /* * Check if the attributes are of the proper format based on the allowed attributes. @@ -166,7 +200,47 @@ private function formatAttributes(array $attributes, array $allowedAttributes): * 'text-padding' => '4px 4px 4px 0' * ] */ - - return []; + + // Check if the passedAttributes is empty or not, if it is, return the default attributes. + if (empty($passedAttributes)) { + return $defaultAttributes; + } + + // 1. Check if the $passedAttributes are of the proper format based on the $allowedAttributes. + + // 2. Check what attributes are the same in the $defaultAttributes and override them. + // 3. Return all the attributes. + + + $result = []; + + + + +// +// foreach ($attributes as $attrName => $attrVal) { +// if ($allowedAttributes && isset($allowedAttributes[$attrName])) { +// $typeConfig = $allowedAttributes[$attrName]; +// $TypeConstructor = initializeType($typeConfig); +// +// if ($TypeConstructor) { +// $type = new $TypeConstructor($attrVal); +// $result[$attrName] = $type->getValue(); +// } +// } else { +// $result[$attrName] = $attrVal; +// } +// } +// +// return $result; +// +// +// +// +// +// +// +// +// return []; } } From 4e4a9fb2974a75b478fdd4ef6f28ae4e6c427bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 3 Dec 2023 14:55:38 +0100 Subject: [PATCH 23/51] Add return type shape to the text element --- src/Elements/BodyComponents/MjText.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php index f7565f9..f4f6111 100644 --- a/src/Elements/BodyComponents/MjText.php +++ b/src/Elements/BodyComponents/MjText.php @@ -162,6 +162,9 @@ public function renderContent(): string return "
$content
"; } + /** + * @return array> + */ public function getStyles(): array { return [ From c1058d52bd18e7ed17294107d200ec923ee261a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:26:12 +0100 Subject: [PATCH 24/51] Add validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This could be a bit overengineered. I'm still not 100% sure 😅 --- src/Elements/Helpers/TypeValidator.php | 94 --------- src/Validation/TypeValidator.php | 179 ++++++++++++++++++ src/Validation/Validatable.php | 10 + src/Validation/Validator.php | 25 +++ .../Validators/AlignmentValidator.php | 16 ++ src/Validation/Validators/ColorValidator.php | 16 ++ .../Validators/FontStyleValidator.php | 16 ++ .../Validators/IntegerValidator.php | 16 ++ .../Validators/MeasureValidator.php | 16 ++ src/Validation/Validators/NumberValidator.php | 16 ++ src/Validation/Validators/StringValidator.php | 16 ++ .../Validators/TextDirectionValidator.php | 16 ++ .../Validators/TextTransformValidator.php | 16 ++ 13 files changed, 358 insertions(+), 94 deletions(-) delete mode 100644 src/Elements/Helpers/TypeValidator.php create mode 100644 src/Validation/TypeValidator.php create mode 100644 src/Validation/Validatable.php create mode 100644 src/Validation/Validator.php create mode 100644 src/Validation/Validators/AlignmentValidator.php create mode 100644 src/Validation/Validators/ColorValidator.php create mode 100644 src/Validation/Validators/FontStyleValidator.php create mode 100644 src/Validation/Validators/IntegerValidator.php create mode 100644 src/Validation/Validators/MeasureValidator.php create mode 100644 src/Validation/Validators/NumberValidator.php create mode 100644 src/Validation/Validators/StringValidator.php create mode 100644 src/Validation/Validators/TextDirectionValidator.php create mode 100644 src/Validation/Validators/TextTransformValidator.php diff --git a/src/Elements/Helpers/TypeValidator.php b/src/Elements/Helpers/TypeValidator.php deleted file mode 100644 index e043810..0000000 --- a/src/Elements/Helpers/TypeValidator.php +++ /dev/null @@ -1,94 +0,0 @@ -namedColors[$color])) { - return true; - } - - // Check if the color is in valid HWB format. - if (preg_match('/^hwb\(\d+,\s*\d+%?,\s*\d+%?\)$/', $color)) { - return true; - } - - // Check if the color is in valid LAB format. - if (preg_match('/^lab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { - return true; - } - - // Check if the color is in valid LCH format. - if (preg_match('/^lch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { - return true; - } - - // Check if the color is in valid Oklab format. - if (preg_match('/^oklab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { - return true; - } - - // Check if the color is in valid Oklch format. - if (preg_match('/^oklch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { - return true; - } - - // Check if the color is in valid light-dark format. - if (in_array(strtolower($color), ['light', 'dark'])) { - return true; - } - - // If none of the formats match, return false. - return false; - } - - public function isValidMeasure(string $measure): bool - { - // Regular expression pattern for a valid measure (number followed by the unit without whitespace). - $pattern = '/^\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i'; - - return preg_match($pattern, $measure) === 1; - } - - public function isNumber(string $number): bool - { - return is_numeric($number); - } - - public function isInteger(string $number): bool - { - return is_numeric($number) && (int) $number == $number; - } -} diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php new file mode 100644 index 0000000..25171af --- /dev/null +++ b/src/Validation/TypeValidator.php @@ -0,0 +1,179 @@ + true, + 'right' => true, + 'center' => true, + 'justify' => true, + 'initial' => true, + 'inherit' => true, + ]; + + private array $allowedFontStyle = [ + 'normal' => true, + 'italic' => true, + 'oblique' => true, + 'initial' => true, + 'inherit' => true, + ]; + + private array $allowedTextDecoration = [ + 'solid' => true, + 'double' => true, + 'dotted' => true, + 'dashed' => true, + 'wavy' => true, + 'initial' => true, + 'inherit' => true, + ]; + + private array $allowedTextTransform = [ + 'none' => true, + 'capitalize' => true, + 'uppercase' => true, + 'lowercase' => true, + 'initial' => true, + 'inherit' => true, + ]; + + public function isValidColor(string $color): bool + { + // Check if the color is in valid RGB format. + if (preg_match('/^rgb\(\d+,\s*\d+,\s*\d+\)$/', $color)) { + return true; + } + + // Check if the color is in valid RGBA format. + if (preg_match('/^rgba\(\d+,\s*\d+,\s*\d+,\s*(0(\.\d+)?|1(\.0+)?)\)$/', $color)) { + return true; + } + + // Check if the color is in valid HEX format (short or long). + if (preg_match('/^#([a-fA-F0-9]{3}){1,2}$/', $color)) { + return true; + } + + // Check if the color is in valid HSL format. + if (preg_match('/^hsl\(\d+,\s*\d+%?,\s*\d+%?\)$/', $color)) { + return true; + } + + // Check if the color is in valid named color format. + if (isset($this->namedColors[$color])) { + return true; + } + + // Check if the color is in valid HWB format. + if (preg_match('/^hwb\(\d+,\s*\d+%?,\s*\d+%?\)$/', $color)) { + return true; + } + + // Check if the color is in valid LAB format. + if (preg_match('/^lab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid LCH format. + if (preg_match('/^lch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid Oklab format. + if (preg_match('/^oklab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid Oklch format. + if (preg_match('/^oklch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { + return true; + } + + // Check if the color is in valid light-dark format. + if (in_array(strtolower($color), ['light', 'dark'])) { + return true; + } + + // If none of the formats match, return false. + return false; + } + + public function isValidMeasure(string $measure): bool + { + // Regular expression pattern for a valid measure (number followed by the unit without whitespace). + $pattern = '/^\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i'; + + return preg_match($pattern, $measure) === 1; + } + + public function isNumber(string $number): bool + { + return is_numeric($number); + } + + public function isInteger(string $number): bool + { + return is_numeric($number) && (int) $number == $number; + } + + public function isAlignment(string $value): bool + { + return isset($this->allowedAlignment[$value]); + } + + /** + * Check if the value is a string. + * + * This check is really not needed, but it's here for consistency. + * The strict type check will take care of this even before we get here, probably. + * + * @param string $value + * + * @return bool + */ + public function isString(string $value): bool + { + return is_string($value); + } + + public function isFontStyle(string $value): bool + { + return isset($this->allowedFontStyle[$value]); + } + + public function isTextDirection(string $direction): bool + { + return isset($this->allowedTextDecoration[$direction]); + } + + public function isTextTransform(string $transform): bool + { + return isset($this->allowedTextTransform[$transform]); + } + + public function getValidator(string $validatorType) + { + $validatorClassName = __NAMESPACE__ . '\\Validators\\' . ucwords($validatorType) . 'Validator'; + + if (!class_exists($validatorClassName)) { + throw new \InvalidArgumentException( + "Validator class $validatorClassName does not exist." + ); + } + + return new $validatorClassName(); + } +} diff --git a/src/Validation/Validatable.php b/src/Validation/Validatable.php new file mode 100644 index 0000000..98ec14b --- /dev/null +++ b/src/Validation/Validatable.php @@ -0,0 +1,10 @@ +isAlignment($value); + } +} diff --git a/src/Validation/Validators/ColorValidator.php b/src/Validation/Validators/ColorValidator.php new file mode 100644 index 0000000..c4b4503 --- /dev/null +++ b/src/Validation/Validators/ColorValidator.php @@ -0,0 +1,16 @@ +isValidColor($color); + } +} diff --git a/src/Validation/Validators/FontStyleValidator.php b/src/Validation/Validators/FontStyleValidator.php new file mode 100644 index 0000000..68311b4 --- /dev/null +++ b/src/Validation/Validators/FontStyleValidator.php @@ -0,0 +1,16 @@ +isFontStyle($value); + } +} diff --git a/src/Validation/Validators/IntegerValidator.php b/src/Validation/Validators/IntegerValidator.php new file mode 100644 index 0000000..37fda8c --- /dev/null +++ b/src/Validation/Validators/IntegerValidator.php @@ -0,0 +1,16 @@ +isInteger($number); + } +} diff --git a/src/Validation/Validators/MeasureValidator.php b/src/Validation/Validators/MeasureValidator.php new file mode 100644 index 0000000..2109c79 --- /dev/null +++ b/src/Validation/Validators/MeasureValidator.php @@ -0,0 +1,16 @@ +isValidMeasure($measure); + } +} diff --git a/src/Validation/Validators/NumberValidator.php b/src/Validation/Validators/NumberValidator.php new file mode 100644 index 0000000..45c19fd --- /dev/null +++ b/src/Validation/Validators/NumberValidator.php @@ -0,0 +1,16 @@ +isNumber($number); + } +} diff --git a/src/Validation/Validators/StringValidator.php b/src/Validation/Validators/StringValidator.php new file mode 100644 index 0000000..9902f55 --- /dev/null +++ b/src/Validation/Validators/StringValidator.php @@ -0,0 +1,16 @@ +isString($value); + } +} diff --git a/src/Validation/Validators/TextDirectionValidator.php b/src/Validation/Validators/TextDirectionValidator.php new file mode 100644 index 0000000..f3f0773 --- /dev/null +++ b/src/Validation/Validators/TextDirectionValidator.php @@ -0,0 +1,16 @@ +isTextDirection($direction); + } +} diff --git a/src/Validation/Validators/TextTransformValidator.php b/src/Validation/Validators/TextTransformValidator.php new file mode 100644 index 0000000..477ed94 --- /dev/null +++ b/src/Validation/Validators/TextTransformValidator.php @@ -0,0 +1,16 @@ +isTextTransform($transform); + } +} From 6440e3385e1a49e9031343c1d289fb5985194c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:26:48 +0100 Subject: [PATCH 25/51] Add additional tests for the element formatting and overriding --- .../Elements/BodyComponents/MjTextTest.php | 44 +++++++++++++++++++ tests/Unit/Renderer/RenderTest.php | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Elements/BodyComponents/MjTextTest.php b/tests/Unit/Elements/BodyComponents/MjTextTest.php index d08111d..bec69c2 100644 --- a/tests/Unit/Elements/BodyComponents/MjTextTest.php +++ b/tests/Unit/Elements/BodyComponents/MjTextTest.php @@ -3,6 +3,8 @@ namespace MadeByDenis\PhpMjmlRenderer\Tests\Unit\Renderer; use MadeByDenis\PhpMjmlRenderer\Elements\BodyComponents\MjText; +use MadeByDenis\PhpMjmlRenderer\Elements\ElementFactory; +use MadeByDenis\PhpMjmlRenderer\Parser\MjmlNode; beforeEach(function () { $this->element = new MjText(); @@ -30,3 +32,45 @@ it('will throw out of bounds exception if the allowed attribute property is not existing', function () { $this->element->getAllowedAttributeData('colour')['name']; })->expectException(\OutOfBoundsException::class); + +it('Will correctly render the desired element', function () { + $textNode = new MjmlNode( + 'mj-text', + [], + 'Hello World!', + false, + null, + ); + + $factory = new ElementFactory(); + + $mjTextElement = $factory->create($textNode); + + expect($mjTextElement)->toBeInstanceOf(MjText::class); + + $out = $mjTextElement->render(); + + expect($out)->toBe('
Hello World!
'); +}); + +it('Will correctly render the desired element with overridden attributes', function () { + $textNode = new MjmlNode( + 'mj-text', + [ + 'color' => '#FF0000', + ], + 'Hello World!', + false, + null, + ); + + $factory = new ElementFactory(); + + $mjTextElement = $factory->create($textNode); + + expect($mjTextElement)->toBeInstanceOf(MjText::class); + + $out = $mjTextElement->render(); + + expect($out)->toBe('
Hello World!
'); +}); diff --git a/tests/Unit/Renderer/RenderTest.php b/tests/Unit/Renderer/RenderTest.php index f5212f8..0dcdcdb 100644 --- a/tests/Unit/Renderer/RenderTest.php +++ b/tests/Unit/Renderer/RenderTest.php @@ -75,4 +75,4 @@ $htmlOut = $this->renderer->render($mjml); expect($htmlOut)->toEqual($htmlExpected); -}); +})->skip(); From cd5804b0d8def5596fe081972d8ed1ba37315460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:27:10 +0100 Subject: [PATCH 26/51] Minor grammar fix in docblock --- src/Node.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Node.php b/src/Node.php index 480adef..1cef953 100644 --- a/src/Node.php +++ b/src/Node.php @@ -29,7 +29,7 @@ interface Node public function getTag(): string; /** - * Check if the current tag is self closing or not + * Check if the current tag is self-closing or not * * @return bool */ From dd5ea445bdcb3f16352de75f8a63b456f962a5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:27:47 +0100 Subject: [PATCH 27/51] Add types to allowed attributes for validation --- src/Elements/BodyComponents/MjText.php | 38 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php index f4f6111..2f9f90e 100644 --- a/src/Elements/BodyComponents/MjText.php +++ b/src/Elements/BodyComponents/MjText.php @@ -38,93 +38,111 @@ class MjText extends AbstractElement protected array $allowedAttributes = [ 'align' => [ 'unit' => 'string', + 'type' => 'alignment', 'description' => 'left/right/center/justify', 'default_value' => 'left', ], 'color' => [ 'unit' => 'color', + 'type' => 'color', 'description' => 'text color', 'default_value' => '#000000', ], 'container-background-color' => [ 'unit' => 'color', + 'type' => 'color', 'description' => 'inner element background color', - 'default_value' => 'n/a', + 'default_value' => 'transparent', ], 'css-class' => [ 'unit' => 'string', + 'type' => 'string', 'description' => 'class name, added to the root HTML element created', - 'default_value' => 'n/a', + 'default_value' => '', ], 'font-family' => [ 'unit' => 'string', + 'type' => 'string', 'description' => 'font', 'default_value' => 'Ubuntu, Helvetica, Arial, sans-serif', ], 'font-size' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'text size', 'default_value' => '13px', ], 'font-style' => [ 'unit' => 'string', + 'type' => 'fontStyle', 'description' => 'normal/italic/oblique', - 'default_value' => 'n/a', + 'default_value' => 'normal', ], 'font-weight' => [ 'unit' => 'number', + 'type' => 'number', 'description' => 'text thickness', - 'default_value' => 'n/a', + 'default_value' => '', ], 'height' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'The height of the element', - 'default_value' => 'n/a', + 'default_value' => '', ], 'letter-spacing' => [ 'unit' => 'px,em', + 'type' => 'measure', 'description' => 'letter spacing', 'default_value' => 'none', ], 'line-height' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'space between the lines', 'default_value' => '1', ], 'padding' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'supports up to 4 parameters', 'default_value' => '10px 25px', ], 'padding-bottom' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'bottom offset', - 'default_value' => 'n/a', + 'default_value' => '0', ], 'padding-left' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'left offset', - 'default_value' => 'n/a', + 'default_value' => '0', ], 'padding-right' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'right offset', - 'default_value' => 'n/a', + 'default_value' => '0', ], 'padding-top' => [ 'unit' => 'px', + 'type' => 'measure', 'description' => 'top offset', - 'default_value' => 'n/a', + 'default_value' => 'initial', ], 'text-decoration' => [ 'unit' => 'string', + 'type' => 'textDecoration', 'description' => 'underline/overline/line-through/none', 'default_value' => 'n/a', ], 'text-transform' => [ 'unit' => 'string', + 'type' => 'textTransform', 'description' => 'uppercase/lowercase/capitalize', - 'default_value' => 'n/a', + 'default_value' => 'none', ], ]; From 07c490150c216219dcdcc95ec7d0d1dcc9e01696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:29:02 +0100 Subject: [PATCH 28/51] Update abstract element class Update global attributes, add validation checks for formatting attributes, still need to add render children method for the body and all elements that have child elements --- src/Elements/AbstractElement.php | 156 ++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 32 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index c493f51..17b6031 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -12,6 +12,8 @@ namespace MadeByDenis\PhpMjmlRenderer\Elements; +use MadeByDenis\PhpMjmlRenderer\Validation\TypeValidator; + /** * Mjml Text Element * @@ -45,13 +47,36 @@ abstract class AbstractElement implements Element protected array $properties = []; - protected array $globalAttributes = []; + protected array $globalAttributes = [ + 'backgroundColor' => '', + 'beforeDoctype' => '', + 'breakpoint' => '480px', + 'classes' => [], + 'classesDefault' => [], + 'defaultAttributes' => [], + 'htmlAttributes' => [], + 'fonts' => '', + 'inlineStyle' => [], + 'headStyle' => [], + 'componentsHeadStyle' => [], + 'headRaw' => [], + 'mediaQueries' => [], + 'preview' => '', + 'style' => [], + 'title' => '', + 'forceOWADesktop' => false, + 'lang' => 'und', + 'dir' => 'auto', + ]; - protected string $context = ''; + /** + * @var array + */ + protected array $context = []; protected string $content = ''; protected ?string $absoluteFilePath = null; - public function __construct(?array $attributes = [], ?string $content = null) + public function __construct(?array $attributes = [], string $content = '') { $this->attributes = $this->formatAttributes( $this->defaultAttributes, @@ -108,7 +133,7 @@ public function getAllowedAttributeData(string $attributeName, string $attribute return $this->allowedAttributes[$attributeName][$attributeProperty]; } - public function getChildContext(): string + public function getChildContext(): array { return $this->context; } @@ -122,6 +147,18 @@ public function getAttribute(string $attributeName) return $this->attributes[$attributeName] ?? null; } + /** + * Return the globally set attributes + * + * @return array + */ + public function getGlobalAttributes(): array + { + return $this->globalAttributes; + } + + // To-do: Override the globally set attributes if we override some from the CLI or some options. + protected function getContent(): string { return trim($this->content); @@ -183,6 +220,64 @@ protected function styles($styles): string return trim($styles); } + protected function renderChildren($children, $options = []) { + + $children = $children ?? $this->children; + + // const { +// props = {}, +// renderer = component => component.render(), +// attributes = {}, +// rawXML = false, +// } = options +// +// children = children || this.props.children +// +// if (rawXML) { +// return children.map(child => jsonToXML(child)).join('\n') +// } +// +// const sibling = children.length +// +// const rawComponents = filter(this.context.components, c => c.isRawElement()) +// const nonRawSiblings = children.filter( +// child => !find(rawComponents, c => c.getTagName() === child.tagName), +// ).length +// +// let output = '' +// let index = 0 +// +// forEach(children, children => { +// const component = initComponent({ +// name: children.tagName, +// initialDatas: { +// ...children, +// attributes: { +// ...attributes, +// ...children.attributes, +// }, +// context: this.getChildContext(), +// props: { +// ...props, +// first: index === 0, +// index, +// last: index + 1 === sibling, +// sibling, +// nonRawSiblings, +// }, +// }, +// }) +// +// if (component !== null) { +// output += renderer(component) +// } +// +// index++ // eslint-disable-line no-plusplus +// }) + + return $output; + } + private function formatAttributes(array $defaultAttributes, array $allowedAttributes, ?array $passedAttributes = []): array { /* @@ -207,40 +302,37 @@ private function formatAttributes(array $defaultAttributes, array $allowedAttrib } // 1. Check if the $passedAttributes are of the proper format based on the $allowedAttributes. + $result = []; - // 2. Check what attributes are the same in the $defaultAttributes and override them. - // 3. Return all the attributes. + // Append `mj-class` to the allowed attributes. + $allowedAttributes['mj-class'] = [ + 'unit' => 'string', + 'description' => 'class name, added to the root HTML element created', + 'default_value' => 'n/a', + ]; + foreach ($passedAttributes as $attrName => $attrVal) { + if (!isset($allowedAttributes[$attrName])) { + throw new \InvalidArgumentException( + "Attribute {$attrName} is not allowed." + ); + } - $result = []; + $typeConfig = $allowedAttributes[$attrName]; + $validator = new TypeValidator(); + $typeValue = $typeConfig['type']; + if (!$validator->getValidator($typeValue)->isValid($validator, $attrVal)) { + throw new \InvalidArgumentException( + "Attribute {$attrName} must be of type {$typeValue}, {$attrVal} given." + ); + } + $result[$attrName] = $attrVal; + } -// -// foreach ($attributes as $attrName => $attrVal) { -// if ($allowedAttributes && isset($allowedAttributes[$attrName])) { -// $typeConfig = $allowedAttributes[$attrName]; -// $TypeConstructor = initializeType($typeConfig); -// -// if ($TypeConstructor) { -// $type = new $TypeConstructor($attrVal); -// $result[$attrName] = $type->getValue(); -// } -// } else { -// $result[$attrName] = $attrVal; -// } -// } -// -// return $result; -// -// -// -// -// -// -// -// -// return []; + // 2. Check what attributes are the same in the $defaultAttributes and override them, and return them. + return $result + $defaultAttributes; } } From 3d9269223dc4bcecf179531a9309f80cbd158763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:29:33 +0100 Subject: [PATCH 29/51] Remove renderContent method from the Element interface --- src/Elements/Element.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Elements/Element.php b/src/Elements/Element.php index b3a37c1..51dd683 100644 --- a/src/Elements/Element.php +++ b/src/Elements/Element.php @@ -20,6 +20,5 @@ interface Element { public function render(): string; - public function renderContent(): string; public function getStyles(): array; } From 5dafac4a4710b6888f9a6c23e7e03fae5c182b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Sun, 28 Jan 2024 19:29:57 +0100 Subject: [PATCH 30/51] Initial addition of body element Still WIP --- src/Elements/BodyComponents/MjBody.php | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/Elements/BodyComponents/MjBody.php diff --git a/src/Elements/BodyComponents/MjBody.php b/src/Elements/BodyComponents/MjBody.php new file mode 100644 index 0000000..a3dbd3c --- /dev/null +++ b/src/Elements/BodyComponents/MjBody.php @@ -0,0 +1,99 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'body background color', + 'default_value' => 'n/a', + ], + 'width' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'The width of the element', + 'default_value' => '600px', + ], + ]; + + protected array $defaultAttributes = [ + 'width' => '600px', + ]; + + public function render(): string + { + $context = $this->getContext(); // To be set with the bg color setter. + + // Fetch from globals. + $globalData = $this->getGlobalAttributes(); + $lang = $globalData['lang']; + $dir = $globalData['dir']; + + $htmlAttributes = $this->getHtmlAttributes([ + 'class' => $this->getAttribute('css-class'), + 'style' => 'div', + $lang, + $dir, + ]); + + // Set bg color. + + $content = $this->renderChildren(); + + return "
$content
"; + } + + public function getChildContext(): array + { + return [ + ...$this->context, + 'containerWidth' => $this->getAttribute('width'), + ]; + } + + /** + * @return array> + */ + public function getStyles(): array + { + return [ + 'div' => [ + 'background-color' => $this->getAttribute('background-color'), + ] + ]; + } +} From 890c7073701308a056e41d99aadf11e0c77404e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 10:13:36 +0100 Subject: [PATCH 31/51] Extract the dependency on the validator out of the validation method --- src/Validation/TypeValidator.php | 2 +- src/Validation/Validatable.php | 2 +- .../Validators/AlignmentValidator.php | 6 ++--- src/Validation/Validators/BaseValidator.php | 23 +++++++++++++++++++ src/Validation/Validators/ColorValidator.php | 6 ++--- .../Validators/FontStyleValidator.php | 6 ++--- .../Validators/IntegerValidator.php | 6 ++--- .../Validators/MeasureValidator.php | 6 ++--- src/Validation/Validators/NumberValidator.php | 6 ++--- src/Validation/Validators/StringValidator.php | 6 ++--- .../Validators/TextDirectionValidator.php | 6 ++--- .../Validators/TextTransformValidator.php | 6 ++--- 12 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 src/Validation/Validators/BaseValidator.php diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php index 25171af..9c51507 100644 --- a/src/Validation/TypeValidator.php +++ b/src/Validation/TypeValidator.php @@ -174,6 +174,6 @@ public function getValidator(string $validatorType) ); } - return new $validatorClassName(); + return new $validatorClassName($this); } } diff --git a/src/Validation/Validatable.php b/src/Validation/Validatable.php index 98ec14b..16a17d7 100644 --- a/src/Validation/Validatable.php +++ b/src/Validation/Validatable.php @@ -6,5 +6,5 @@ interface Validatable { - public function isValid(Validator $validator, string $input): bool; + public function isValid(string $input): bool; } diff --git a/src/Validation/Validators/AlignmentValidator.php b/src/Validation/Validators/AlignmentValidator.php index d4fcf8c..7732b1f 100644 --- a/src/Validation/Validators/AlignmentValidator.php +++ b/src/Validation/Validators/AlignmentValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class AlignmentValidator implements Validatable +class AlignmentValidator extends BaseValidator { - public function isValid(Validator $validator, string $value): bool + public function isValid(string $value): bool { - return $validator->isAlignment($value); + return $this->validator->isAlignment($value); } } diff --git a/src/Validation/Validators/BaseValidator.php b/src/Validation/Validators/BaseValidator.php new file mode 100644 index 0000000..15ec783 --- /dev/null +++ b/src/Validation/Validators/BaseValidator.php @@ -0,0 +1,23 @@ +validator = $validator; + } + + abstract public function isValid(string $input): bool; +} diff --git a/src/Validation/Validators/ColorValidator.php b/src/Validation/Validators/ColorValidator.php index c4b4503..98a5f2f 100644 --- a/src/Validation/Validators/ColorValidator.php +++ b/src/Validation/Validators/ColorValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class ColorValidator implements Validatable +class ColorValidator extends BaseValidator { - public function isValid(Validator $validator, string $color): bool + public function isValid(string $color): bool { - return $validator->isValidColor($color); + return $this->validator->isValidColor($color); } } diff --git a/src/Validation/Validators/FontStyleValidator.php b/src/Validation/Validators/FontStyleValidator.php index 68311b4..e69893a 100644 --- a/src/Validation/Validators/FontStyleValidator.php +++ b/src/Validation/Validators/FontStyleValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class FontStyleValidator implements Validatable +class FontStyleValidator extends BaseValidator { - public function isValid(Validator $validator, string $value): bool + public function isValid(string $value): bool { - return $validator->isFontStyle($value); + return $this->validator->isFontStyle($value); } } diff --git a/src/Validation/Validators/IntegerValidator.php b/src/Validation/Validators/IntegerValidator.php index 37fda8c..7b77709 100644 --- a/src/Validation/Validators/IntegerValidator.php +++ b/src/Validation/Validators/IntegerValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class IntegerValidator implements Validatable +class IntegerValidator extends BaseValidator { - public function isValid(Validator $validator, string $number): bool + public function isValid(string $number): bool { - return $validator->isInteger($number); + return $this->validator->isInteger($number); } } diff --git a/src/Validation/Validators/MeasureValidator.php b/src/Validation/Validators/MeasureValidator.php index 2109c79..f587add 100644 --- a/src/Validation/Validators/MeasureValidator.php +++ b/src/Validation/Validators/MeasureValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class MeasureValidator implements Validatable +class MeasureValidator extends BaseValidator { - public function isValid(Validator $validator, string $measure): bool + public function isValid(string $measure): bool { - return $validator->isValidMeasure($measure); + return $this->validator->isValidMeasure($measure); } } diff --git a/src/Validation/Validators/NumberValidator.php b/src/Validation/Validators/NumberValidator.php index 45c19fd..a08aa52 100644 --- a/src/Validation/Validators/NumberValidator.php +++ b/src/Validation/Validators/NumberValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class NumberValidator implements Validatable +class NumberValidator extends BaseValidator { - public function isValid(Validator $validator, string $number): bool + public function isValid(string $number): bool { - return $validator->isNumber($number); + return $this->validator->isNumber($number); } } diff --git a/src/Validation/Validators/StringValidator.php b/src/Validation/Validators/StringValidator.php index 9902f55..570b7d0 100644 --- a/src/Validation/Validators/StringValidator.php +++ b/src/Validation/Validators/StringValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class StringValidator implements Validatable +class StringValidator extends BaseValidator { - public function isValid(Validator $validator, string $value): bool + public function isValid(string $value): bool { - return $validator->isString($value); + return $this->validator->isString($value); } } diff --git a/src/Validation/Validators/TextDirectionValidator.php b/src/Validation/Validators/TextDirectionValidator.php index f3f0773..0d02a40 100644 --- a/src/Validation/Validators/TextDirectionValidator.php +++ b/src/Validation/Validators/TextDirectionValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class TextDirectionValidator implements Validatable +class TextDirectionValidator extends BaseValidator { - public function isValid(Validator $validator, string $direction): bool + public function isValid(string $direction): bool { - return $validator->isTextDirection($direction); + return $this->validator->isTextDirection($direction); } } diff --git a/src/Validation/Validators/TextTransformValidator.php b/src/Validation/Validators/TextTransformValidator.php index 477ed94..402ea8d 100644 --- a/src/Validation/Validators/TextTransformValidator.php +++ b/src/Validation/Validators/TextTransformValidator.php @@ -7,10 +7,10 @@ use MadeByDenis\PhpMjmlRenderer\Validation\Validatable; use MadeByDenis\PhpMjmlRenderer\Validation\Validator; -class TextTransformValidator implements Validatable +class TextTransformValidator extends BaseValidator { - public function isValid(Validator $validator, string $transform): bool + public function isValid(string $transform): bool { - return $validator->isTextTransform($transform); + return $this->validator->isTextTransform($transform); } } From 2f1b31cec6eda228b09e7c4349d8831aacee298a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 10:14:22 +0100 Subject: [PATCH 32/51] Refactor validation code No need to call upon the class, and pass it to the method, when we can just pass it as a dependency. --- src/Elements/AbstractElement.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index 17b6031..eab5dd7 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -323,7 +323,7 @@ private function formatAttributes(array $defaultAttributes, array $allowedAttrib $typeValue = $typeConfig['type']; - if (!$validator->getValidator($typeValue)->isValid($validator, $attrVal)) { + if (!$validator->getValidator($typeValue)->isValid($attrVal)) { throw new \InvalidArgumentException( "Attribute {$attrName} must be of type {$typeValue}, {$attrVal} given." ); From ffce08afca3d559f4d4f0a3671ee5ce8700a14e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 11:54:44 +0100 Subject: [PATCH 33/51] Update the coverage composer command --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a0fe631..42e012f 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "test:types": "@php ./vendor/bin/phpstan", "test:style": "@php ./vendor/bin/phpcs", "test:unit": "@php ./vendor/bin/pest", - "test:coverage": "@php ./vendor/bin/pest --coverage", + "test:coverage": "@php -dxdebug.mode=coverage ./vendor/bin/pest --coverage", "test": [ "@test:style", "@test:types", From 67a1da33cdef81da08c5a6237ab53902e1c6bdaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 11:55:48 +0100 Subject: [PATCH 34/51] Update validators Update the text decoration one, update colors, fix lab and lch checks. Even though I think only rgb, rgba and hex and named colors are needed. --- src/Validation/TypeValidator.php | 22 +++++++++++++--------- src/Validation/Validator.php | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php index 9c51507..cd03135 100644 --- a/src/Validation/TypeValidator.php +++ b/src/Validation/TypeValidator.php @@ -6,13 +6,12 @@ class TypeValidator implements Validator { - /** * List of named colors. * * @var String[] */ - private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen']; + private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'inherit', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',]; private array $allowedAlignment = [ 'left' => true, @@ -72,8 +71,13 @@ public function isValidColor(string $color): bool return true; } + // Check if the color is in valid HSLA format. + if (preg_match('/^hsla\(\d+,\s*\d+%?,\s*\d+%?,\s*(0(\.\d+)?|1(\.0+)?)\)$/', $color)) { + return true; + } + // Check if the color is in valid named color format. - if (isset($this->namedColors[$color])) { + if (isset(array_flip($this->namedColors)[$color])) { return true; } @@ -83,13 +87,13 @@ public function isValidColor(string $color): bool } // Check if the color is in valid LAB format. - if (preg_match('/^lab\(\d+(\.\d+)?,\s*-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\)$/', $color)) { - return true; + if (preg_match('/^lab\(\d+(\.\d+)?%?,?\s?\d+(\.\d+)?,?\s?\d+(\.\d+)|(\s?\/?\s?\d+(\.\d+))?\)$/', $color)) { + return true; } // Check if the color is in valid LCH format. - if (preg_match('/^lch\(\d+(\.\d+)?,\s*\d+(\.\d+)?,\s*\d+(\.\d+)?\)$/', $color)) { - return true; + if (preg_match('/^lch\(\d+(\.\d+)?%?,?\s?\d+(\.\d+)?,?\s?\d+(\.\d+)|(\s?\/?\s?\d+(\.\d+))?\)$/', $color)) { + return true; } // Check if the color is in valid Oklab format. @@ -114,7 +118,7 @@ public function isValidColor(string $color): bool public function isValidMeasure(string $measure): bool { // Regular expression pattern for a valid measure (number followed by the unit without whitespace). - $pattern = '/^\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i'; + $pattern = '/^0$|^\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|%)$/i'; return preg_match($pattern, $measure) === 1; } @@ -154,7 +158,7 @@ public function isFontStyle(string $value): bool return isset($this->allowedFontStyle[$value]); } - public function isTextDirection(string $direction): bool + public function isTextDecoration(string $direction): bool { return isset($this->allowedTextDecoration[$direction]); } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 5d6f5ef..7d52262 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -20,6 +20,6 @@ public function isInteger(string $number): bool; public function isAlignment(string $value): bool; public function isString(string $string): bool; public function isFontStyle(string $value): bool; - public function isTextDirection(string $direction): bool; + public function isTextDecoration(string $decoration): bool; public function isTextTransform(string $transform): bool; } From d0c6e8af8181a2d54eeecf9d635b5403ed99cdb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 11:56:07 +0100 Subject: [PATCH 35/51] Update tests for validators --- tests/Datasets/ValidatorInputs.php | 183 ++++++++++++++++++ .../Elements/BodyComponents/MjTextTest.php | 22 +++ tests/Unit/Elements/ElementFactoryTest.php | 4 +- tests/Unit/Renderer/RenderTest.php | 2 +- tests/Unit/Validation/ValidatorTest.php | 94 +++++++++ 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 tests/Datasets/ValidatorInputs.php create mode 100644 tests/Unit/Validation/ValidatorTest.php diff --git a/tests/Datasets/ValidatorInputs.php b/tests/Datasets/ValidatorInputs.php new file mode 100644 index 0000000..f4afaa4 --- /dev/null +++ b/tests/Datasets/ValidatorInputs.php @@ -0,0 +1,183 @@ +toBe('
Hello World!
'); }); + +it('Will correctly throw exception if we are passing a non-existing type', function () { + $textNode = new MjmlNode( + 'mj-text', + [ + 'colors' => '#FF0000 #000000', + ], + 'Hello World!', + false, + null, + ); + + $factory = new ElementFactory(); + + $mjTextElement = $factory->create($textNode); + + expect($mjTextElement)->toBeInstanceOf(MjText::class); + + $mjTextElement->render(); +})->throws(\InvalidArgumentException::class, 'Attribute colors is not allowed.'); + + diff --git a/tests/Unit/Elements/ElementFactoryTest.php b/tests/Unit/Elements/ElementFactoryTest.php index 6216a68..2bf7059 100644 --- a/tests/Unit/Elements/ElementFactoryTest.php +++ b/tests/Unit/Elements/ElementFactoryTest.php @@ -13,9 +13,7 @@ it('Will correctly return class of the desired element', function () { $textNode = new MjmlNode( 'mj-text', - [ - 'mj-class' => 'blue big' - ], + [], 'Hello World!', false, null, diff --git a/tests/Unit/Renderer/RenderTest.php b/tests/Unit/Renderer/RenderTest.php index 0dcdcdb..fae8d63 100644 --- a/tests/Unit/Renderer/RenderTest.php +++ b/tests/Unit/Renderer/RenderTest.php @@ -47,7 +47,7 @@ $htmlOut = $this->renderer->render($mjml); expect($htmlOut)->toEqual($htmlExpected); -}); +})->skip(); it('renders the MJML to correct HTML version with attributes', function () { $mjml = <<<'MJML' diff --git a/tests/Unit/Validation/ValidatorTest.php b/tests/Unit/Validation/ValidatorTest.php new file mode 100644 index 0000000..d27657c --- /dev/null +++ b/tests/Unit/Validation/ValidatorTest.php @@ -0,0 +1,94 @@ +validator = new TypeValidator(); +}); + +it('Will throw error if non-existing validator is passed', function () { + $this->validator->getValidator('Colors'); +})->throws(\InvalidArgumentException::class, 'Validator class MadeByDenis\PhpMjmlRenderer\Validation\Validators\ColorsValidator does not exist.'); + +it('Will return true if valid color is passed', function ($color) { + expect($this->validator->isValidColor($color))->toBeTrue(); +})->with('valid colors'); + +it('Will return false if invalid color is passed', function ($color) { + expect($this->validator->isValidColor($color))->toBeFalse(); +})->with('invalid colors'); + +it('Will return true if correct numeric value is passed', function ($value) { + expect($this->validator->isNumber($value))->toBeTrue(); +})->with('numeric values'); + +it('Will return false if invalid numeric value is passed', function ($value) { + expect($this->validator->isNumber($value))->toBeFalse(); +})->with('non-numeric values'); + +it('Will return true if correct integer value is passed', function ($value) { + expect($this->validator->isInteger($value))->toBeTrue(); +})->with('integer values'); + +it('Will return false if invalid integer value is passed', function ($value) { + expect($this->validator->isInteger($value))->toBeFalse(); +})->with('non-integer values'); + +it('Will return true if correct alignment value is passed', function ($value) { + expect($this->validator->isAlignment($value))->toBeTrue(); +})->with('alignment values'); + +it('Will return false if invalid alignment value is passed', function ($value) { + expect($this->validator->isAlignment($value))->toBeFalse(); +})->with('non-alignment values'); + +it('Will return true if correct measure value is passed', function ($value) { + expect($this->validator->isValidMeasure($value))->toBeTrue(); +})->with('valid lengths'); + +it('Will return false if invalid measure value is passed', function ($value) { + expect($this->validator->isValidMeasure($value))->toBeFalse(); +})->with('invalid lengths'); + +it('Will return true if correct percentages are passed', function ($value) { + expect($this->validator->isValidMeasure($value))->toBeTrue(); +})->with('valid percentages'); + +it('Will return false if invalid percentages are passed', function ($value) { + expect($this->validator->isValidMeasure($value))->toBeFalse(); +})->with('invalid percentages'); + +it('Will return true if correct strings are passed', function ($value) { + expect($this->validator->isString($value))->toBeTrue(); +})->with('valid strings'); + +it('Will return false if invalid strings are passed', function ($value) { + expect($this->validator->isString($value))->toBeFalse(); +})->with('invalid strings')->skip('Cannot test this because of strict type conversions.'); + +it('Will return true if correct font styles are passed', function ($value) { + expect($this->validator->isFontStyle($value))->toBeTrue(); +})->with('valid font styles'); + +it('Will return false if invalid font styles are passed', function ($value) { + expect($this->validator->isFontStyle($value))->toBeFalse(); +})->with('invalid font styles'); + +it('Will return true if correct text decoration are passed', function ($value) { + expect($this->validator->isTextDecoration($value))->toBeTrue(); +})->with('valid text decoration'); + +it('Will return false if invalid text decoration are passed', function ($value) { + expect($this->validator->isTextDecoration($value))->toBeFalse(); +})->with('invalid text decoration'); + +it('Will return true if correct text transform are passed', function ($value) { + expect($this->validator->isTextTransform($value))->toBeTrue(); +})->with('valid text transform'); + +it('Will return false if invalid text transform are passed', function ($value) { + expect($this->validator->isTextTransform($value))->toBeFalse(); +})->with('invalid text transform'); + From ff6813b33b0d29c04b64a5772a87f012c1cabeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Mon, 29 Jan 2024 11:58:57 +0100 Subject: [PATCH 36/51] Fix phpcs issues --- src/Elements/AbstractElement.php | 15 +++++++++------ src/Elements/ElementFactory.php | 6 +++--- src/Validation/TypeValidator.php | 6 +++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index eab5dd7..a947b10 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -220,7 +220,8 @@ protected function styles($styles): string return trim($styles); } - protected function renderChildren($children, $options = []) { + protected function renderChildren($children, $options = []) + { $children = $children ?? $this->children; @@ -274,12 +275,14 @@ protected function renderChildren($children, $options = []) { // // index++ // eslint-disable-line no-plusplus // }) + return $output; + } - return $output; - } - - private function formatAttributes(array $defaultAttributes, array $allowedAttributes, ?array $passedAttributes = []): array - { + private function formatAttributes( + array $defaultAttributes, + array $allowedAttributes, + ?array $passedAttributes = [] + ): array { /* * Check if the attributes are of the proper format based on the allowed attributes. * For instance, if you pass a non string value to the 'align' attribute, you should get an error. diff --git a/src/Elements/ElementFactory.php b/src/Elements/ElementFactory.php index 89eb504..6de62ff 100644 --- a/src/Elements/ElementFactory.php +++ b/src/Elements/ElementFactory.php @@ -57,9 +57,9 @@ private static function getTagClass(string $tag): string // We can do this, because we are using PSR-4 convention. $classFQN = 'MadeByDenis\\PhpMjmlRenderer\\Elements' . self::getElementClass( - __DIR__, - $fileInfo->getPathName() - ); + __DIR__, + $fileInfo->getPathName() + ); $classNames[$classFQN::TAG_NAME] = $classFQN; } } diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php index cd03135..a08d14a 100644 --- a/src/Validation/TypeValidator.php +++ b/src/Validation/TypeValidator.php @@ -11,7 +11,7 @@ class TypeValidator implements Validator * * @var String[] */ - private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'inherit', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',]; + private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'inherit', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',]; // phpcs:ignore Generic.Files.LineLength private array $allowedAlignment = [ 'left' => true, @@ -88,12 +88,12 @@ public function isValidColor(string $color): bool // Check if the color is in valid LAB format. if (preg_match('/^lab\(\d+(\.\d+)?%?,?\s?\d+(\.\d+)?,?\s?\d+(\.\d+)|(\s?\/?\s?\d+(\.\d+))?\)$/', $color)) { - return true; + return true; } // Check if the color is in valid LCH format. if (preg_match('/^lch\(\d+(\.\d+)?%?,?\s?\d+(\.\d+)?,?\s?\d+(\.\d+)|(\s?\/?\s?\d+(\.\d+))?\)$/', $color)) { - return true; + return true; } // Check if the color is in valid Oklab format. From b002f5f844675e1210933a8c7f6f545a5bad28da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:30:43 +0200 Subject: [PATCH 37/51] Update packages --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 42e012f..6b2c69a 100644 --- a/composer.json +++ b/composer.json @@ -21,12 +21,12 @@ } ], "require": { - "php": ">=7.4", + "php": ">=8.1", "ext-simplexml": "*" }, "require-dev": { "captainhook/captainhook": "^5.11", - "pestphp/pest": "^1.22", + "pestphp/pest": "^3", "php-parallel-lint/php-parallel-lint": "^1.3", "phpcompatibility/php-compatibility": "^9.3", "phpcsstandards/php_codesniffer": "^3.7", From 17ddb99fe4f192d3e9da449a73d265d4e8d284e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:32:26 +0200 Subject: [PATCH 38/51] WIP Update abstract element Added a lot of methods to the abstract element. Still need to test and cleanup. --- src/Elements/AbstractElement.php | 221 ++++++++++++++++++++++--------- 1 file changed, 156 insertions(+), 65 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index a947b10..3f83a4c 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -12,6 +12,7 @@ namespace MadeByDenis\PhpMjmlRenderer\Elements; +use MadeByDenis\PhpMjmlRenderer\Elements\Helpers\{BodyHelpers, CssClasses, JsonHelper}; use MadeByDenis\PhpMjmlRenderer\Validation\TypeValidator; /** @@ -23,6 +24,10 @@ */ abstract class AbstractElement implements Element { + use JsonHelper; + use CssClasses; + use BodyHelpers; + public const TAG_NAME = ''; public const ENDING_TAG = false; @@ -43,11 +48,18 @@ abstract class AbstractElement implements Element */ protected array $attributes = []; - protected array $children = []; - protected array $properties = []; - protected array $globalAttributes = [ + protected ?array $children; + + /** + * @var array + */ + protected array $context = []; + protected string $content = ''; + protected ?string $absoluteFilePath = null; + + private array $globalAttributes = [ 'backgroundColor' => '', 'beforeDoctype' => '', 'breakpoint' => '480px', @@ -69,14 +81,7 @@ abstract class AbstractElement implements Element 'dir' => 'auto', ]; - /** - * @var array - */ - protected array $context = []; - protected string $content = ''; - protected ?string $absoluteFilePath = null; - - public function __construct(?array $attributes = [], string $content = '') + public function __construct(?array $attributes = [], string $content = '', ?array $childNodes = []) { $this->attributes = $this->formatAttributes( $this->defaultAttributes, @@ -85,6 +90,7 @@ public function __construct(?array $attributes = [], string $content = '') ); $this->content = $content; + $this->children = $childNodes; } public function isEndingTag(): bool @@ -110,9 +116,8 @@ public function isRawElement(): bool * * @return array|string Array of properties in case the specific property is empty, property value if not. * - * @throws \OutOfBoundsException In case attribute name is wrong or property doesn't exist. */ - public function getAllowedAttributeData(string $attributeName, string $attributeProperty = '') + public function getAllowedAttributeData(string $attributeName, string $attributeProperty = ''): array | string { if (!isset($this->allowedAttributes[$attributeName])) { throw new \OutOfBoundsException( @@ -142,7 +147,7 @@ public function getChildContext(): array * @param string $attributeName * @return mixed|null */ - public function getAttribute(string $attributeName) + public function getAttribute(string $attributeName): mixed { return $this->attributes[$attributeName] ?? null; } @@ -157,8 +162,18 @@ public function getGlobalAttributes(): array return $this->globalAttributes; } + public function setGlobalAttributes($attribute, $value): void + { + $this->globalAttributes[$attribute] = $value; + } + // To-do: Override the globally set attributes if we override some from the CLI or some options. + protected function getChildren(): ?array + { + return $this->children; + } + protected function getContent(): string { return trim($this->content); @@ -180,6 +195,9 @@ protected function getHtmlAttributes(array $attributes): ?string 'default' => $this->defaultAttributes, ]; + // Remove numerical keys from the array. + $attributes = array_filter($attributes, fn($key) => !is_numeric($key), ARRAY_FILTER_USE_KEY); + $nonEmpty = array_filter($attributes, fn($element) => !empty($element)); $attrOut = ''; @@ -189,6 +207,12 @@ protected function getHtmlAttributes(array $attributes): ?string $specialAttributes[$key] : $specialAttributes['default']; + if (is_array($value)) { + $value = implode('; ', array_map(function ($val, $key) { + return "$key: $val"; + }, $value, array_keys($value))); + } + $attrOut .= "$key=\"$value\""; }); @@ -213,6 +237,11 @@ protected function styles($styles): string array_walk($stylesArray, function ($val, $key) use (&$styles) { if (!empty($val)) { + + if (is_array($val)) { + $val = implode(' ', $val); + } + $styles .= "$key:$val;"; } }); @@ -220,64 +249,97 @@ protected function styles($styles): string return trim($styles); } - protected function renderChildren($children, $options = []) + protected function getShorthandAttrValue($attribute, $direction): int + { + $mjAttributeDirection = $this->getAttribute("$attribute-$direction"); + $mjAttribute = $this->getAttribute($attribute); + + if ($mjAttributeDirection) { + return (int)$mjAttributeDirection; + } + + if (!$mjAttribute) { + return 0; + } + + return $this->shorthandParser($mjAttribute, $direction); + } + + protected function getShorthandBorderValue($direction, $attribute = 'border'): int { + $borderDirection = $direction && $this->getAttribute("$attribute-$direction"); + $border = $this->getAttribute($attribute); + return $this->borderParser($borderDirection || $border || '0'); + } + + protected function renderChildren($children, $options = []): string + { $children = $children ?? $this->children; - // const { -// props = {}, -// renderer = component => component.render(), -// attributes = {}, -// rawXML = false, -// } = options -// -// children = children || this.props.children -// -// if (rawXML) { -// return children.map(child => jsonToXML(child)).join('\n') -// } -// -// const sibling = children.length -// -// const rawComponents = filter(this.context.components, c => c.isRawElement()) -// const nonRawSiblings = children.filter( -// child => !find(rawComponents, c => c.getTagName() === child.tagName), -// ).length -// -// let output = '' -// let index = 0 -// -// forEach(children, children => { -// const component = initComponent({ -// name: children.tagName, -// initialDatas: { -// ...children, -// attributes: { -// ...attributes, -// ...children.attributes, -// }, -// context: this.getChildContext(), -// props: { -// ...props, -// first: index === 0, -// index, -// last: index + 1 === sibling, -// sibling, -// nonRawSiblings, -// }, -// }, -// }) -// -// if (component !== null) { -// output += renderer(component) -// } -// -// index++ // eslint-disable-line no-plusplus -// }) + if ($this->isRawElement()) { + return implode("\n", array_map(function ($child) { + return $this->jsonToXML($child); + }, $children)); + } + + $output = ''; + + foreach ($children as $child) { + // Render child components. + $output .= ElementFactory::create($child)->render(); + } + return $output; } + protected function getBoxWidths(): array + { + + ['containerWidth' => $containerWidth] = $this->context; + + $parsedWidth = (int)$containerWidth; + + $paddings = + $this->getShorthandAttrValue('padding', 'right') . + $this->getShorthandAttrValue('padding', 'left'); + + $borders = + $this->getShorthandBorderValue('right') . + $this->getShorthandBorderValue('left'); + + return [ + 'totalWidth' => $parsedWidth, + 'borders' => $borders, + 'paddings' => $paddings, + 'box' => $parsedWidth - $paddings - $borders, + ]; + } + + protected function widthParser($width, $options = []): array + { + $defaultOptions = [ + 'parseFloatToInt' => true, + ]; + + $options = $defaultOptions + $options; + + $widthUnit = preg_match('/[\d.,]*(\D*)$/', $width, $matches) ? $matches[1] : 'px'; + + $unitParsers = [ + 'default' => 'intval', + 'px' => 'intval', + '%' => $options['parseFloatToInt'] ? 'intval' : 'floatval', + ]; + + $parser = $unitParsers[$widthUnit] ?? $unitParsers['default']; + + return [ + 'parsedWidth' => $parser($width), + 'unit' => $widthUnit, + ]; + } + private function formatAttributes( array $defaultAttributes, array $allowedAttributes, @@ -338,4 +400,33 @@ private function formatAttributes( // 2. Check what attributes are the same in the $defaultAttributes and override them, and return them. return $result + $defaultAttributes; } + + private function shortHandParser($cssValue, $direction): int + { + // Convert to PHP. + $splittedCssValue = preg_split('/\s+/', trim($cssValue), 4); + + switch (count($splittedCssValue)) { + case 2: + $directions = ['top' => 0, 'bottom' => 0, 'left' => 1, 'right' => 1]; + break; + case 3: + $directions = ['top' => 0, 'left' => 1, 'right' => 1, 'bottom' => 2]; + break; + case 4: + $directions = ['top' => 0, 'right' => 1, 'bottom' => 2, 'left' => 3]; + break; + case 1: + default: + return (int)$cssValue; + } + + return (int)$splittedCssValue[$directions[$direction]] ?? 0; + } + + private function borderParser($border): int + { + preg_match('/(?:^| )(\d+)/', $border, $matches); + return isset($matches[1]) ? (int)$matches[1] : 0; + } } From 07c614ab127422378f644b02e715b29b3ac9316e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:50:29 +0200 Subject: [PATCH 39/51] WIP Modify the body component add column and section components Still a WIP, need to clean column and section components and add tests for them. --- src/Elements/BodyComponents/MjBody.php | 9 +- src/Elements/BodyComponents/MjColumn.php | 465 +++++++++++++++ src/Elements/BodyComponents/MjSection.php | 669 ++++++++++++++++++++++ 3 files changed, 1139 insertions(+), 4 deletions(-) create mode 100644 src/Elements/BodyComponents/MjColumn.php create mode 100644 src/Elements/BodyComponents/MjSection.php diff --git a/src/Elements/BodyComponents/MjBody.php b/src/Elements/BodyComponents/MjBody.php index a3dbd3c..7bfe34f 100644 --- a/src/Elements/BodyComponents/MjBody.php +++ b/src/Elements/BodyComponents/MjBody.php @@ -40,7 +40,7 @@ class MjBody extends AbstractElement 'unit' => 'color', 'type' => 'color', 'description' => 'body background color', - 'default_value' => 'n/a', + 'default_value' => '#FFFFFF', ], 'width' => [ 'unit' => 'px', @@ -56,10 +56,11 @@ class MjBody extends AbstractElement public function render(): string { - $context = $this->getContext(); // To be set with the bg color setter. + $this->setBackgroundColor($this->getAttribute('background-color')); // Fetch from globals. $globalData = $this->getGlobalAttributes(); + $lang = $globalData['lang']; $dir = $globalData['dir']; @@ -70,9 +71,9 @@ public function render(): string $dir, ]); - // Set bg color. + $children = $this->getChildren(); - $content = $this->renderChildren(); + $content = $this->renderChildren($children); return "
$content
"; } diff --git a/src/Elements/BodyComponents/MjColumn.php b/src/Elements/BodyComponents/MjColumn.php new file mode 100644 index 0000000..6f96f07 --- /dev/null +++ b/src/Elements/BodyComponents/MjColumn.php @@ -0,0 +1,465 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'column background color', + 'default_value' => '#FFFFFF', + ], + 'border' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column border format', + 'default_value' => 'none', + ], + 'border-bottom' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column border bottom format', + 'default_value' => '', + ], + 'border-left' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column border bottom format', + 'default_value' => '', + ], + 'border-radius' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'column border radius format', + 'default_value' => '', + ], + 'border-right' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column border right format', + 'default_value' => '', + ], + 'border-top' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column border top format', + 'default_value' => '', + ], + 'direction' => [ + 'unit' => 'string', + 'type' => 'direction', + 'description' => 'set the display order of direct children', + 'default_value' => 'ltr', + ], + 'inner-background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'column background color', + 'default_value' => 'none', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'supports up to 4 parameters', + 'default_value' => '0', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'bottom offset', + 'default_value' => '0', + ], + 'padding-left' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'left offset', + 'default_value' => '0', + ], + 'padding-right' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'right offset', + 'default_value' => '0', + ], + 'padding-top' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'top offset', + 'default_value' => 'initial', + ], + 'inner-border' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column inner border format', + 'default_value' => 'none', + ], + 'inner-border-bottom' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column inner border bottom format', + 'default_value' => 'none', + ], + 'inner-border-left' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column inner border left format', + 'default_value' => 'none', + ], + 'inner-border-radius' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'column inner border radius format', + 'default_value' => 'none', + ], + 'inner-border-right' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column inner border right format', + 'default_value' => 'none', + ], + 'inner-border-top' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'column inner border top format', + 'default_value' => 'none', + ], + 'vertical-align' => [ + 'unit' => 'string', + 'type' => 'verticalAlign', + 'description' => 'column vertical alignment', + 'default_value' => 'top', + ], + 'width' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'The width of the element', + 'default_value' => '600px', + ], + ]; + + protected array $defaultAttributes = [ + 'direction' => 'ltr', + 'vertical-align' => 'top', + ]; + + public function render(): string + { + $columnClass = $this->getColumnClass(); + $classesName = "$columnClass mj-outlook-group-fix"; + $classAttribute = $this->getAttribute('css-class'); + + if ($this->getAttribute('css-class')) { + $classesName .= " $classAttribute"; + } + + $columnAttributes = $this->getHtmlAttributes([ + 'class' => $classesName, + 'style' => 'div', + ]); + + $content = $this->hasGutter() ? $this->renderGutter() : $this->renderColumn(); + + return "
+ $content +
"; + } + + public function getChildContext(): array + { + ['containerWidth' => $parentWidth] = $this->context; + + $nonRawSiblings = count(array_filter($this->getChildren(), function ($child) { + return !$child->isRawElement(); + })); + + $innerBorders = $this->getShorthandBorderValue('left', 'inner-border') . $this->getShorthandBorderValue('right', 'inner-border'); + + $containerWidth = (float)$parentWidth / $nonRawSiblings; + $containerWidth = $this->getAttribute('width') ?? "{$containerWidth}px"; + + ['borders' => $borders, 'paddings' => $paddings] = $this->getBoxWidths(); + + $allPaddings = $paddings . $borders . $innerBorders; + + ['unit' => $unit, 'parsedWidth' => $parsedWidth] = $this->widthParser($containerWidth, [ + 'parseFloatToInt' => false, + ]); + + if ($unit === '%') { + $width = ((float)$parentWidth * $parsedWidth) / 100 - $allPaddings; + } else { + $width = $parsedWidth - $allPaddings; + } + + $containerWidth = "{$width}px"; + + return [ + ...$this->context, + 'containerWidth' => $containerWidth, + ]; + } + + /** + * @return array> + */ + public function getStyles(): array + { + $tableStyle = [ + 'background-color' => $this->getAttribute('background-color'), + 'border' => $this->getAttribute('border'), + 'border-bottom' => $this->getAttribute('border-bottom'), + 'border-left' => $this->getAttribute('border-left'), + 'border-radius' => $this->getAttribute('border-radius'), + 'border-right' => $this->getAttribute('border-right'), + 'border-top' => $this->getAttribute('border-top'), + 'vertical-align' => $this->getAttribute('vertical-align'), + ]; + + return [ + 'div' => [ + 'font-size' => '0px', + 'text-align' => 'left', + 'direction' => $this->getAttribute('direction'), + 'display' => 'inline-block', + 'vertical-align' => $this->getAttribute('vertical-align'), + 'width' => $this->getMobileWidth(), + ], + 'table' => [ + ...$this->hasGutter() + ? [ + 'background-color' => $this->getAttribute('inner-background-color'), + 'border' => $this->getAttribute('inner-border'), + 'border-bottom' => $this->getAttribute('inner-border-bottom'), + 'border-left' => $this->getAttribute('inner-border-left'), + 'border-radius' => $this->getAttribute('inner-border-radius'), + 'border-right' => $this->getAttribute('inner-border-right'), + 'border-top' => $this->getAttribute('inner-border-top'), + ] + : $tableStyle, + ], + 'tdOutlook' => [ + 'vertical-align' => $this->getAttribute('vertical-align'), + 'width' => $this->getWidthAsPixel(), + ], + 'gutter' => [ + ...$tableStyle, + 'padding' => $this->getAttribute('padding'), + 'padding-top' => $this->getAttribute('padding-top'), + 'padding-right' => $this->getAttribute('padding-right'), + 'padding-bottom' => $this->getAttribute('padding-bottom'), + 'padding-left' => $this->getAttribute('padding-left'), + ], + ]; + } + + private function renderGutter(): string + { + $tableAttributes = $this->getHtmlAttributes([ + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'role' => 'presentation', + 'width' => '100%', + ]); + + $gutterAttributes = $this->getHtmlAttributes([ + 'style' => 'gutter', + ]); + + $column = $this->renderColumn(); + + return " + + + + + +
+ $column +
"; + } + + private function renderColumn(): string + { + + $children = $this->getChildren(); + + $tableAttributes = $this->getHtmlAttributes([ + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'role' => 'presentation', + 'style' => 'table', + 'width' => '100%', + ]); + + $content = $this->renderChildren($children, []); + + return " + + $content + +
"; + + +// ${this.renderChildren(children, { +// renderer: (component) => +// component.constructor.isRawElement() +// ? component.render() +// : ` +// +// +// ${component.render()} +// +// +// `, +// })} + } + + private function getMobileWidth() + { +// const { containerWidth } = this.context +// const { nonRawSiblings } = this.props +// const width = this.getAttribute('width') +// const mobileWidth = this.getAttribute('mobileWidth') +// +// if (mobileWidth !== 'mobileWidth') { +// return '100%' +// } +// if (width === undefined) { +// return `${parseInt(100 / nonRawSiblings, 10)}%` +// } +// +// const { unit, parsedWidth } = widthParser(width, { +// parseFloatToInt: false, +// }) +// +// switch (unit) { +// case '%': +// return width +// case 'px': +// default: +// return `${(parsedWidth / parseInt(containerWidth, 10)) * 100}%` +// } + } + + private function getWidthAsPixel(): string + { + ['containerWidth' => $containerWidth] = $this->context; + + [$unit, $parsedWidth] = $this->widthParser($this->getParsedWidth(true), [ + 'parseFloatToInt' => false, + ]); + + if ($unit === '%') { + $pixelValue = ((float)$containerWidth * $parsedWidth) / 100; + + return "{$pixelValue}px"; + } + + return "{$parsedWidth}px"; + } + + private function getParsedWidth(bool $toString = false): array | string + { + $nonRawSiblings = count(array_filter($this->getChildren(), function ($child) { + // Get the element from the node, then check if it's raw. + $element = ElementFactory::create($child); + return !$element->isRawElement(); + })); + + $percentage = 100 / $nonRawSiblings; + + $width = $this->getAttribute('width') ?? "$percentage%"; + + ['unit' => $unit, 'parsedWidth' => $parsedWidth] = $this->widthParser($width, [ + 'parseFloatToInt' => false, + ]); + + if ($toString) { + return "$parsedWidth$unit"; + } + + return [ + $unit, + $parsedWidth, + ]; + } + + private function getColumnClass(): string + { + [$unit, $parsedWidth] = $this->getParsedWidth(); + + $formattedClassNb = str_replace('.', '-', (string)$parsedWidth); + + $className = match ($unit) { + '%' => "mj-column-per-$formattedClassNb", + default => "mj-column-px-$formattedClassNb", + }; + + $this->addMediaQuery($className, $parsedWidth, $unit); + + return $className; + } + + private function hasGutter(): bool + { + $attributes = ['padding', 'padding-bottom', 'padding-left', 'padding-right', 'padding-top']; + + return array_reduce($attributes, function ($carry, $attr) { + return $carry || $this->getAttribute($attr) !== null; + }, false); + } +} diff --git a/src/Elements/BodyComponents/MjSection.php b/src/Elements/BodyComponents/MjSection.php new file mode 100644 index 0000000..61c1c59 --- /dev/null +++ b/src/Elements/BodyComponents/MjSection.php @@ -0,0 +1,669 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'section background color', + 'default_value' => '#FFFFFF', + ], + 'background-url' => [ + 'unit' => 'string', + 'type' => 'url', + 'description' => 'section background image url', + 'default_value' => '', + ], + 'background-repeat' => [ + 'unit' => 'string', + 'type' => 'backgroundRepeat', // To-do: create a new validator for this. + 'description' => 'section background repeat value', + 'default_value' => 'repeat', + ], + 'background-size' => [ + 'unit' => 'string', + 'type' => 'backgroundSize', // To-do: create a new validator for this. + 'description' => 'section background size', + 'default_value' => 'auto', + ], + 'background-position' => [ + 'unit' => 'string', + 'type' => 'backgroundPosition', // To-do: create a new validator for this. + 'description' => 'section background position', + 'default_value' => 'top center', + ], + 'background-position-x' => [ + 'unit' => 'string', + 'type' => 'backgroundPosition', + 'description' => 'section background position x value', + 'default_value' => '', + ], + 'background-position-y' => [ + 'unit' => 'string', + 'type' => 'backgroundPosition', + 'description' => 'section background position y value', + 'default_value' => '', + ], + 'border' => [ + 'unit' => 'string', + 'type' => 'border', // To-do: create a new validator for this. + 'description' => 'section border format', + 'default_value' => 'none', + ], + 'border-bottom' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'section border bottom format', + 'default_value' => '', + ], + 'border-left' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'section border bottom format', + 'default_value' => '', + ], + 'border-radius' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'section border radius format', + 'default_value' => '', + ], + 'border-right' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'section border right format', + 'default_value' => '', + ], + 'border-top' => [ + 'unit' => 'string', + 'type' => 'border', + 'description' => 'section border top format', + 'default_value' => '', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name, added to the root HTML element created', + 'default_value' => '', + ], + 'direction' => [ + 'unit' => 'string', + 'type' => 'direction', // To-do: create a new validator for this. + 'description' => 'set the display order of direct children', + 'default_value' => 'ltr', + ], + 'full-width' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'make the section full-width', + 'default_value' => '', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'supports up to 4 parameters', + 'default_value' => '20px 0', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'bottom offset', + 'default_value' => '0', + ], + 'padding-left' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'left offset', + 'default_value' => '0', + ], + 'padding-right' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'right offset', + 'default_value' => '0', + ], + 'padding-top' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'top offset', + 'default_value' => 'initial', + ], + 'text-align' => [ + 'unit' => 'string', + 'type' => 'alignment', + 'description' => 'left/right/center/justify', + 'default_value' => 'center', + ], + 'text-padding' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'supports up to 4 parameters', + 'default_value' => '4px 4px 4px 0', + ], + ]; + + protected array $defaultAttributes = [ + 'background-repeat' => 'repeat', + 'background-size' => 'auto', + 'background-position' => 'top center', + 'direction' => 'ltr', + 'padding' => '20px 0', + 'text-align' => 'center', + 'text-padding' => '4px 4px 4px 0', + ]; + + public function render(): string + { + return $this->isFullWidth() ? $this->renderFullWidth() : $this->renderSimple(); + } + + public function getChildContext(): array + { + return [ + ...$this->context, + 'containerWidth' => $this->getAttribute('width'), + ]; + } + + /** + * @return array> + */ + public function getStyles(): array + { + ['containerWidth' => $containerWidth] = $this->getChildContext(); + $fullWidth = $this->isFullWidth(); + + $background = $this->getAttribute('background-url') ? + [ + 'background' => $this->getBackground(), + 'background-position' => $this->getBackgroundString(), + 'background-repeat' => $this->getAttribute('background-repeat'), + 'background-size' => $this->getAttribute('background-size'), + ] : + [ + 'background' => $this->getAttribute('background-color'), + 'background-color' => $this->getAttribute('background-color'), + ]; + + return [ + 'div' => [ + 'fullWidth' => $fullWidth ? [] : $background, + 'margin' => '0px auto', + 'border-radius' => $this->getAttribute('border-radius'), + 'max-width' => $containerWidth, + ], + 'innerDiv' => [ + 'line-height' => 0, + 'font-size' => 0, + ], + 'tableFullwidth' => [ + 'fullWidth' => $fullWidth ? $background : [], + 'width' => '100%', + 'border-radius' => $this->getAttribute('border-radius'), + ], + 'table' => [ + 'fullWidth' => $fullWidth ? [] : $background, + 'width' => '100%', + 'border-radius' => $this->getAttribute('border-radius'), + ], + 'td' => [ + 'border' => $this->getAttribute('border'), + 'border-bottom' => $this->getAttribute('border-bottom'), + 'border-left' => $this->getAttribute('border-left'), + 'border-right' => $this->getAttribute('border-right'), + 'border-top' => $this->getAttribute('border-top'), + 'direction' => $this->getAttribute('direction'), + 'font-size' => '0px', + 'padding' => $this->getAttribute('padding'), + 'padding-bottom' => $this->getAttribute('padding-bottom'), + 'padding-left' => $this->getAttribute('padding-left'), + 'padding-right' => $this->getAttribute('padding-right'), + 'padding-top' => $this->getAttribute('padding-top'), + 'text-align' => $this->getAttribute('text-align'), + ], + ]; + } + + private function isFullWidth(): bool + { + return $this->getAttribute('full-width') === 'full-width'; + } + + private function hasBackground(): bool + { + return $this->getAttribute('background-url') != null; + } + + private function renderFullWidth(): string + { + $innerContent = "{$this->renderBefore()} + {$this->renderSection()} + {$this->renderAfter()}"; + + $content = $this->hasBackground() ? + $this->renderWithBackground($innerContent) : + $innerContent; + + $tableAttributes = $this->getHtmlAttributes([ + 'align' => 'center', + 'class' => $this->getAttribute('css-class'), + 'background' => $this->getAttribute('background-url'), + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'role' => 'presentation', + 'style' => 'tableFullwidth', + ]); + + return " + + + + + +
$content
"; + } + + private function renderSimple(): ?string + { + $section = $this->renderSection(); + + return + $this->renderBefore() . + $this->hasBackground() ? $this->renderWithBackground($section) : $section . + $this->renderAfter(); + } + + private function renderSection(): string + { + $hasBackground = $this->hasBackground(); + + $sectionAttributes = $this->getHtmlAttributes([ + 'class' => $this->isFullWidth() ? null : $this->getAttribute('css-class'), + 'style' => 'div', + ]); + + $innerDivAttributes = $this->getHtmlAttributes(['style' => 'innerDiv']); + + $tableAttributes = $this->getHtmlAttributes([ + 'align' => 'center', + 'background' => $this->isFullWidth() ? null : $this->getAttribute('background-url'), + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'role' => 'presentation', + 'style' => 'table', + ]); + + $tdAttributes = $this->getHtmlAttributes([ + 'style' => 'td', + ]); + + $bgWrapperOpener = $hasBackground ? "
" : ''; + $bgWrapperCloser = $hasBackground ? '
' : ''; + + // Get child nodes. + $children = $this->getChildren(); + $content = $this->renderWrappedChildren($children); + + return " +
+ $bgWrapperOpener + + + + + + +
+ + $content + +
+ $bgWrapperCloser +
+ "; + } + + private function renderBefore(): string + { + ['containerWidth' => $containerWidth] = $this->getChildContext(); + + $bgColorAttr = $this->getAttribute('background-color') ? + ['bgcolor' => $this->getAttribute('background-color')] : + []; + + $tableAttributes = [ + 'align' => 'center', + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'class' => $this->suffixCssClasses($this->getAttribute('css-class'), 'outlook'), + 'role' => 'presentation', + 'style' => ['width' => $containerWidth], + 'width' => (int)$containerWidth, + ]; + + $tableAttributes = $this->getHtmlAttributes($tableAttributes + $bgColorAttr); + + return ""; + } + + private function renderAfter(): string + { + return ""; + } + + private function renderWrappedChildren(?array $children): string + { + $content = $this->renderChildren($children, []); + +// ${this.renderChildren(children, { +// renderer: (component) => +// component.constructor.isRawElement() +// ? component.render() +// : " +// +// ${component.render()} +// +// ", +// })} + + return " + $content + "; + } + + private function renderWithBackground($content): string + { + $fullWidth = $this->isFullWidth(); + ['containerWidth' => $containerWidth] = $this->getChildContext(); + + $isPercentage = fn($str) => preg_match('/^\d+(\.\d+)?%$/', $str); + + $vSizeAttributes = []; + ['posX' => $bgPosX, 'posY' => $bgPosY] = $this->getBackgroundPosition(); + + switch ($bgPosX) { + case 'left': + $bgPosX = '0%'; + break; + case 'center': + $bgPosX = '50%'; + break; + case 'right': + $bgPosX = '100%'; + break; + default: + if (!$isPercentage($bgPosX)) { + $bgPosX = '50%'; + } + break; + } + + switch ($bgPosY) { + case 'top': + $bgPosY = '0%'; + break; + case 'center': + $bgPosY = '50%'; + break; + case 'bottom': + $bgPosY = '100%'; + break; + default: + if (!$isPercentage($bgPosY)) { + $bgPosY = '0%'; + } + break; + } + + [[$vOriginX, $vPosX], [$vOriginY, $vPosY]] = array_map( + function ($coordinate) use ($bgPosX, $bgPosY, $isPercentage) { + $isX = $coordinate === 'x'; + $bgRepeat = $this->getAttribute('background-repeat') === 'repeat'; + + $pos = $isX ? $bgPosX : $bgPosY; + + if ($isPercentage($pos)) { + preg_match('/^(\d+(\.\d+)?)%$/', $pos, $percentages); + $percentageValue = $percentages[1]; // Check if this match is correct! + $decimal = (int)$percentageValue / 100; + + if ($bgRepeat) { + $pos = $decimal; + $origin = $decimal; + } else { + $pos = (-50 + $decimal * 100) / 100; + $origin = (-50 + $decimal * 100) / 100; + } + } elseif ($bgRepeat) { + $origin = $isX ? '0.5' : '0'; + $pos = $isX ? '0.5' : '0'; + } else { + $origin = $isX ? '0' : '-0.5'; + $pos = $isX ? '0' : '-0.5'; + } + + return [$origin, $pos]; + }, + ['x', 'y'] + ); + + if ( + $this->getAttribute('background-size') === 'cover' || + $this->getAttribute('background-size') === 'contain' + ) { + $vSizeAttributes = [ + 'size' => '1,1', + 'aspect' => + $this->getAttribute('background-size') === 'cover' ? + 'atleast' : + 'atmost', + ]; + } elseif ($this->getAttribute('background-size') !== 'auto') { + $bgSplit = explode(' ', $this->getAttribute('background-size')); + + if (count($bgSplit) === 1) { + $vSizeAttributes = [ + 'size' => $this->getAttribute('background-size'), + 'aspect' => 'atmost', + ]; + } else { + $vSizeAttributes = [ + 'size' => implode(',', $bgSplit), + ]; + } + } + + $vmlType = $this->getAttribute('background-repeat') === 'no-repeat' ? 'frame' : 'tile'; + + if ($this->getAttribute('background-size') === 'auto') { + $vmlType = 'tile'; + [[$vOriginX, $vPosX], [$vOriginY, $vPosY]] = [ + [0.5, 0.5], + [0, 0], + ]; + } + + $vRectAttributes = $this->gethtmlAttributes([ + 'style' => $fullWidth ? + ['mso-width-percent' => '1000'] : + ['width' => $containerWidth], + 'xmlns:v' => 'urn:schemas-microsoft-com:vml', + 'fill' => 'true', + 'stroke' => 'false', + ]); + + $vFillAttributes = $this->gethtmlAttributes([ + 'origin' => "$vOriginX, $vOriginY", + 'position' => "$vPosX, $vPosY", + 'src' => $this->getAttribute('background-url'), + 'color' => $this->getAttribute('background-color'), + 'type' => $vmlType, + ...$vSizeAttributes, + ]); + + return " + $content + "; + } + + private function getBackground(): string + { + $bgUrl = $this->getAttribute('background-url'); + $bgSize = $this->getAttribute('background-size'); + + return $this->makeBackgroundString([ + $this->getAttribute('background-color'), + ...[ + $this->hasBackground() ? + [ + "url('$bgUrl')", + $this->getBackgroundString(), + "/ $bgSize", + $this->getAttribute('background-repeat'), + ] : [] + ] + ]); + } + + private function getBackgroundString(): string + { + ['posX' => $posX, 'posY' => $posY] = $this->getBackgroundPosition(); + + return "$posX $posY"; + } + + private function getBackgroundPosition(): array + { + ['x' => $x, 'y' => $y] = $this->parseBackgroundPosition(); + + return [ + 'posX' => $this->getAttribute('background-position-x') ?? $x, + 'posY' => $this->getAttribute('background-position-y') ?? $y, + ]; + } + + private function parseBackgroundPosition(): array + { + $posSplit = explode(' ', $this->getAttribute('background-position')); + + if (count($posSplit) === 1) { + $val = $posSplit[0]; + + if (in_array($val, ['top', 'bottom'], true)) { + return [ + 'x' => 'center', + 'y' => $val, + ]; + } + + return [ + 'x' => $val, + 'y' => 'center', + ]; + } + + if (count($posSplit) === 2) { + $val1 = $posSplit[0]; + $val2 = $posSplit[1]; + + if ( + in_array($val1, ['top', 'bottom'], true) || + ($val1 === 'center' && in_array($val2, ['left', 'right'], true)) + ) { + return [ + 'x' => $val2, + 'y' => $val1, + ]; + } + + return [ + 'x' => $val1, + 'y' => $val2, + ]; + } + + // Default. + return [ + 'x' => 'center', + 'y' => 'top', + ]; + } + + private function makeBackgroundString(array $array): string + { + return implode(' ', array_filter($array)); + } +} From 82ea712129d452e2c354569813bef8b40f38c666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:50:52 +0200 Subject: [PATCH 40/51] Modify element factory --- src/Elements/ElementFactory.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Elements/ElementFactory.php b/src/Elements/ElementFactory.php index 6de62ff..3778a75 100644 --- a/src/Elements/ElementFactory.php +++ b/src/Elements/ElementFactory.php @@ -34,9 +34,10 @@ public static function create(Node $node): Element $tag = self::$node->getTag(); $class = self::getTagClass($tag); $attributes = $node->getAttributes(); - $content = $node->getInnerContent(); + $content = $node->getInnerContent() ?? ''; + $children = $node->getChildren() ?? []; - return new $class($attributes, $content); // phpcs:ignore PSR12.Classes.ClassInstantiation.MissingParentheses + return new $class($attributes, $content, $children); // phpcs:ignore PSR12.Classes.ClassInstantiation.MissingParentheses } private static function getTagClass(string $tag): string From 755b52d632a7b5ea21492cfa315c558a89937feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:51:34 +0200 Subject: [PATCH 41/51] Modify the type validator Modify color names. --- src/Validation/TypeValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php index a08d14a..ffe9e90 100644 --- a/src/Validation/TypeValidator.php +++ b/src/Validation/TypeValidator.php @@ -11,7 +11,7 @@ class TypeValidator implements Validator * * @var String[] */ - private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'inherit', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',]; // phpcs:ignore Generic.Files.LineLength + private array $namedColors = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'inherit', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen', 'none']; // phpcs:ignore Generic.Files.LineLength private array $allowedAlignment = [ 'left' => true, From 01003011f987aea3754408a98bd088bcadad85c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:52:07 +0200 Subject: [PATCH 42/51] Add various helper traits --- src/Elements/Helpers/BodyHelpers.php | 35 ++++++++++++++++++++++++++++ src/Elements/Helpers/CssClasses.php | 16 +++++++++++++ src/Elements/Helpers/JsonHelper.php | 30 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/Elements/Helpers/BodyHelpers.php create mode 100644 src/Elements/Helpers/CssClasses.php create mode 100644 src/Elements/Helpers/JsonHelper.php diff --git a/src/Elements/Helpers/BodyHelpers.php b/src/Elements/Helpers/BodyHelpers.php new file mode 100644 index 0000000..8cdb9aa --- /dev/null +++ b/src/Elements/Helpers/BodyHelpers.php @@ -0,0 +1,35 @@ +setGlobalAttributes('mediaQueries', [ + $className => [ + 'width' => "{$parsedWidth}{$unit} !important;", + 'max-width' => "{$parsedWidth}{$unit};", + ] + ]); + } + + protected function setBackgroundColor($color): void + { + $this->setGlobalAttributes('backgroundColor', $color); + } + + protected function addHeadStyle($identifier, $headStyle): void + { + $this->setGlobalAttributes('headStyle', [ + $identifier => $headStyle + ]); + } + + protected function addComponentHeadStyle($headStyle): void + { + $this->setGlobalAttributes('componentsHeadStyle', $headStyle); + } +} diff --git a/src/Elements/Helpers/CssClasses.php b/src/Elements/Helpers/CssClasses.php new file mode 100644 index 0000000..9160e47 --- /dev/null +++ b/src/Elements/Helpers/CssClasses.php @@ -0,0 +1,16 @@ +getTag(); + $attributes = $node->getAttributes(); + $children = $node->getChildren(); + $content = $node->getInnerContent(); + + $subNode = !empty($children) ? implode("\n", array_map([$this, 'jsonToXML'], $children)) : $content; + + $stringAttrs = array_reduce(array_keys($attributes), function ($carry, $attr) use ($attributes) { + return $carry . "{$attr}=\"{$attributes[$attr]}\" "; + }, ''); + + $stringAttrs = trim($stringAttrs); + + return $stringAttrs === '' ? + "<{$tagName}>{$subNode}" : + "<{$tagName} {$stringAttrs}>{$subNode}"; + } +} From e5a245d9c185aacd45c500705b0cf94e784af839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 22:52:32 +0200 Subject: [PATCH 43/51] Add body test Still failing as it is a WIP --- .../Elements/BodyComponents/MjBodyTest.php | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/Unit/Elements/BodyComponents/MjBodyTest.php diff --git a/tests/Unit/Elements/BodyComponents/MjBodyTest.php b/tests/Unit/Elements/BodyComponents/MjBodyTest.php new file mode 100644 index 0000000..da08f34 --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjBodyTest.php @@ -0,0 +1,133 @@ +element = new MjBody(); +}); + +it('is not ending tag', function () { + expect($this->element->isEndingTag())->toBeFalse(); +}); + +it('returns the correct component name', function () { + expect($this->element->getTagName())->toBe('mj-body'); +}); + +it('returns the correct default attribute', function () { + expect($this->element->getAllowedAttributeData('background-color')) + ->toBeArray() + ->and($this->element->getAllowedAttributeData('background-color')['default_value']) + ->toBe('#FFFFFF'); +}); + +it('will throw out of bounds exception if the allowed attribute is not existing', function () { + $this->element->getAllowedAttributeData('colour'); +})->expectException(\OutOfBoundsException::class); + +it('will throw out of bounds exception if the allowed attribute property is not existing', function () { + $this->element->getAllowedAttributeData('colour')['name']; +})->expectException(\OutOfBoundsException::class); + +it('Will correctly render the desired element', function () { + $bodyNode = new MjmlNode( + 'mj-body', + null, + null, + false, + [ + new MjmlNode( + 'mj-section', + null, + null, + false, + [ + new MjmlNode( + 'mj-column', + null, + null, + false, + [ + new MjmlNode( + 'mj-text', + null, + 'Hello World!', + false, + null, + ), + ] + ), + ] + ), + ] + ); + + $factory = new ElementFactory(); + + $mjBodyElement = $factory->create($bodyNode); + + expect($mjBodyElement)->toBeInstanceOf(MjBody::class); + + $out = $mjBodyElement->render(); + + expect($out)->toBe(' +
+
+ + + + + + +
+
+ + + + + + +
+
+ Hello world +
+
+
+
+
+
+'); +}); From 6ed9c8cd522f9345a2a0a6fb8ba3bb3a8a103ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:02:06 +0200 Subject: [PATCH 44/51] Update packages --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 6b2c69a..69586b7 100644 --- a/composer.json +++ b/composer.json @@ -21,16 +21,16 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.3", "ext-simplexml": "*" }, "require-dev": { - "captainhook/captainhook": "^5.11", - "pestphp/pest": "^3", + "captainhook/captainhook": "^5", + "pestphp/pest": "^3.8", "php-parallel-lint/php-parallel-lint": "^1.3", "phpcompatibility/php-compatibility": "^9.3", - "phpcsstandards/php_codesniffer": "^3.7", - "phpstan/phpstan": "^1.9" + "phpcsstandards/php_codesniffer": "^3.13", + "phpstan/phpstan": "^2.1" }, "autoload": { "psr-4": { From f6362bf033af652ca3e087a0fc3ab14e575c25c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:02:21 +0200 Subject: [PATCH 45/51] Update phpunit config --- phpunit.xml | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 26d9dac..2f30475 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,19 @@ - - - - ./tests/Unit/ - - - - - ./src - - - - - - + + + + ./tests/Unit/ + + + + + + + + + + + ./src + + From 653699100d02d9f6ffb9df323f56624796607512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:10:44 +0200 Subject: [PATCH 46/51] Add colors to phpcs config --- phpcs.xml.dist | 1 + 1 file changed, 1 insertion(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 2f94c16..500daab 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -7,6 +7,7 @@ + From 89ba28fef7c16655e8faff404a85969120c17efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:10:54 +0200 Subject: [PATCH 47/51] Fix phpstan config --- phpstan.neon.dist | 3 --- 1 file changed, 3 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7fa33f8..7ce9594 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,9 +2,6 @@ parameters: level: max paths: - src - - checkMissingIterableValueType: true reportUnmatchedIgnoredErrors: true universalObjectCratesClasses: - SimpleXMLElement - From 02bcb02f3a861262e503e0105e12acc86615bc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:11:11 +0200 Subject: [PATCH 48/51] Fix code style issues --- src/Elements/AbstractElement.php | 1 - src/Elements/BodyComponents/MjColumn.php | 7 +++++-- src/Elements/Helpers/CssClasses.php | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php index 3f83a4c..01aae92 100644 --- a/src/Elements/AbstractElement.php +++ b/src/Elements/AbstractElement.php @@ -237,7 +237,6 @@ protected function styles($styles): string array_walk($stylesArray, function ($val, $key) use (&$styles) { if (!empty($val)) { - if (is_array($val)) { $val = implode(' ', $val); } diff --git a/src/Elements/BodyComponents/MjColumn.php b/src/Elements/BodyComponents/MjColumn.php index 6f96f07..002de2f 100644 --- a/src/Elements/BodyComponents/MjColumn.php +++ b/src/Elements/BodyComponents/MjColumn.php @@ -206,7 +206,10 @@ public function getChildContext(): array return !$child->isRawElement(); })); - $innerBorders = $this->getShorthandBorderValue('left', 'inner-border') . $this->getShorthandBorderValue('right', 'inner-border'); + $innerBorders = $this->getShorthandBorderValue('left', 'inner-border') . $this->getShorthandBorderValue( + 'right', + 'inner-border' + ); $containerWidth = (float)$parentWidth / $nonRawSiblings; $containerWidth = $this->getAttribute('width') ?? "{$containerWidth}px"; @@ -329,7 +332,7 @@ private function renderColumn(): string $content = $this->renderChildren($children, []); - return " + return "
$content diff --git a/src/Elements/Helpers/CssClasses.php b/src/Elements/Helpers/CssClasses.php index 9160e47..d4d9f47 100644 --- a/src/Elements/Helpers/CssClasses.php +++ b/src/Elements/Helpers/CssClasses.php @@ -11,6 +11,6 @@ public function suffixCssClasses($classes, $suffix): string return $classes ? implode(' ', array_map(function ($className) use ($suffix) { return $className . '-' . $suffix; - }, explode(' ', $classes))) : ''; + }, explode(' ', $classes))) : ''; } } From 9cb90e2577a46d46b0aed554560a4b98d43d4d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:11:37 +0200 Subject: [PATCH 49/51] Fix one test and skip a failing test for now --- tests/Unit/Elements/BodyComponents/MjBodyTest.php | 2 +- tests/Unit/Parser/ParserTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Elements/BodyComponents/MjBodyTest.php b/tests/Unit/Elements/BodyComponents/MjBodyTest.php index da08f34..31cced0 100644 --- a/tests/Unit/Elements/BodyComponents/MjBodyTest.php +++ b/tests/Unit/Elements/BodyComponents/MjBodyTest.php @@ -130,4 +130,4 @@ class="" '); -}); +})->skip(); diff --git a/tests/Unit/Parser/ParserTest.php b/tests/Unit/Parser/ParserTest.php index 6de80b6..abb9ae6 100644 --- a/tests/Unit/Parser/ParserTest.php +++ b/tests/Unit/Parser/ParserTest.php @@ -167,4 +167,4 @@ MJML; $this->parser->parse($mjml); -})->expectExceptionMessage('simplexml_load_string(): Entity:'); +})->expectExceptionMessage('Badly formatted MJML code.'); From e99d5961386ccbd78f59f5ea7257ba70a9898dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:14:05 +0200 Subject: [PATCH 50/51] Update workflow Raised php version to 8.3 minimum. --- .github/workflows/ci.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc062e2..d9e205c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: "Set up PHP" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8.3" coverage: "none" - name: "Checkout code" @@ -64,17 +64,14 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.0', '7.4' ] + php: [ '8.3', '8.4', '8.5' ] allowed_failure: [ false ] dependencies: - "lowest" - "highest" include: - - php: '8.2' + - php: '8.5' allowed_failure: true - exclude: - - php: '8.1' - dependencies: "lowest" # Fails due to PHP parser error on a specific version. steps: - name: "Set up PHP" uses: "shivammathur/setup-php@v2" @@ -116,10 +113,10 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.0', '7.4' ] + php: [ '8.3', '8.4', '8.5' ] allowed_failure: [ false ] include: - - php: '8.2' + - php: '8.5' allowed_failure: true steps: - name: "Set up PHP" @@ -152,10 +149,10 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.0', '7.4' ] + php: [ '8.3', '8.4', '8.5' ] allowed_failure: [ false ] include: - - php: '8.2' + - php: '8.5' allowed_failure: true steps: - name: "Set up PHP" From fe1c1f55887fca1058a0a5845f6157f0c1b29c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Thu, 17 Jul 2025 23:17:52 +0200 Subject: [PATCH 51/51] Fix workflow Exclude 8.5 from not allowed to fail matrix --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9e205c..b2f0819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.3', '8.4', '8.5' ] + php: [ '8.3', '8.4' ] allowed_failure: [ false ] dependencies: - "lowest" @@ -113,7 +113,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.3', '8.4', '8.5' ] + php: [ '8.3', '8.4' ] allowed_failure: [ false ] include: - php: '8.5' @@ -149,7 +149,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.3', '8.4', '8.5' ] + php: [ '8.3', '8.4' ] allowed_failure: [ false ] include: - php: '8.5'