diff --git a/composer.json b/composer.json index 992065b..42e012f 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": { @@ -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", diff --git a/src/Elements/.gitkeep b/src/Elements/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Elements/AbstractElement.php b/src/Elements/AbstractElement.php new file mode 100644 index 0000000..a947b10 --- /dev/null +++ b/src/Elements/AbstractElement.php @@ -0,0 +1,341 @@ + + */ + protected array $defaultAttributes = []; + + /** + * @var array> + */ + protected array $allowedAttributes = []; + + /** + * @var array + */ + protected array $attributes = []; + + protected array $children = []; + + protected array $properties = []; + + 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', + ]; + + /** + * @var array + */ + protected array $context = []; + protected string $content = ''; + protected ?string $absoluteFilePath = null; + + public function __construct(?array $attributes = [], string $content = '') + { + $this->attributes = $this->formatAttributes( + $this->defaultAttributes, + $this->allowedAttributes, + $attributes, + ); + + $this->content = $content; + } + + public function isEndingTag(): bool + { + return static::ENDING_TAG; + } + + public function getTagName(): string + { + return static::TAG_NAME; + } + + public function isRawElement(): bool + { + return $this->rawElement; + } + + /** + * Get the allowed attribute info + * + * @param string $attributeName Name of the attribute. + * @param string $attributeProperty Name of attribute property. + * + * @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 = '') + { + 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]; + } + + public function getChildContext(): array + { + return $this->context; + } + + /** + * @param string $attributeName + * @return mixed|null + */ + 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); + } + + /** + * @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' => $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]) ? + $specialAttributes[$key] : + $specialAttributes['default']; + + $attrOut .= "$key=\"$value\""; + }); + + return trim($attrOut); + } + + abstract public function getStyles(): array; + + protected function styles($styles): string + { + $stylesArray = []; + + if (!empty($styles)) { + if (is_string($styles)) { + $stylesArray = $this->getStyles()[$styles]; + } else { + $stylesArray = $styles; + } + } + + $styles = ''; + + array_walk($stylesArray, function ($val, $key) use (&$styles) { + if (!empty($val)) { + $styles .= "$key:$val;"; + } + }); + + 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 { + /* + * 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' + * ] + */ + + // 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. + $result = []; + + // 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." + ); + } + + $typeConfig = $allowedAttributes[$attrName]; + $validator = new TypeValidator(); + + $typeValue = $typeConfig['type']; + + if (!$validator->getValidator($typeValue)->isValid($attrVal)) { + throw new \InvalidArgumentException( + "Attribute {$attrName} must be of type {$typeValue}, {$attrVal} given." + ); + } + + $result[$attrName] = $attrVal; + } + + // 2. Check what attributes are the same in the $defaultAttributes and override them, and return them. + return $result + $defaultAttributes; + } +} 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'), + ] + ]; + } +} diff --git a/src/Elements/BodyComponents/MjText.php b/src/Elements/BodyComponents/MjText.php new file mode 100644 index 0000000..2f9f90e --- /dev/null +++ b/src/Elements/BodyComponents/MjText.php @@ -0,0 +1,204 @@ +> + */ + 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' => 'transparent', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name, added to the root HTML element created', + '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' => 'normal', + ], + 'font-weight' => [ + 'unit' => 'number', + 'type' => 'number', + 'description' => 'text thickness', + 'default_value' => '', + ], + 'height' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'The height of the element', + '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' => '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-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' => 'none', + ], + ]; + + 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 + { + $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 + { + $htmlAttributes = $this->getHtmlAttributes([ + 'style' => 'text', + ]); + + $content = $this->getContent(); + + return "
$content
"; + } + + /** + * @return array> + */ + public function getStyles(): array + { + 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'), + ] + ]; + } +} diff --git a/src/Elements/Element.php b/src/Elements/Element.php new file mode 100644 index 0000000..51dd683 --- /dev/null +++ b/src/Elements/Element.php @@ -0,0 +1,24 @@ +getTag(); + $class = self::getTagClass($tag); + $attributes = $node->getAttributes(); + $content = $node->getInnerContent(); + + return new $class($attributes, $content); // 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) { + // 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() + ); + $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); + } + + /** + * 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; + } +} 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"; + } +} 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 */ 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( 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, ''); } } diff --git a/src/Validation/TypeValidator.php b/src/Validation/TypeValidator.php new file mode 100644 index 0000000..a08d14a --- /dev/null +++ b/src/Validation/TypeValidator.php @@ -0,0 +1,183 @@ + 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 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(array_flip($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+)|(\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+)|(\s?\/?\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 = '/^0$|^\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 isTextDecoration(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($this); + } +} diff --git a/src/Validation/Validatable.php b/src/Validation/Validatable.php new file mode 100644 index 0000000..16a17d7 --- /dev/null +++ b/src/Validation/Validatable.php @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..98a5f2f --- /dev/null +++ b/src/Validation/Validators/ColorValidator.php @@ -0,0 +1,16 @@ +validator->isValidColor($color); + } +} diff --git a/src/Validation/Validators/FontStyleValidator.php b/src/Validation/Validators/FontStyleValidator.php new file mode 100644 index 0000000..e69893a --- /dev/null +++ b/src/Validation/Validators/FontStyleValidator.php @@ -0,0 +1,16 @@ +validator->isFontStyle($value); + } +} diff --git a/src/Validation/Validators/IntegerValidator.php b/src/Validation/Validators/IntegerValidator.php new file mode 100644 index 0000000..7b77709 --- /dev/null +++ b/src/Validation/Validators/IntegerValidator.php @@ -0,0 +1,16 @@ +validator->isInteger($number); + } +} diff --git a/src/Validation/Validators/MeasureValidator.php b/src/Validation/Validators/MeasureValidator.php new file mode 100644 index 0000000..f587add --- /dev/null +++ b/src/Validation/Validators/MeasureValidator.php @@ -0,0 +1,16 @@ +validator->isValidMeasure($measure); + } +} diff --git a/src/Validation/Validators/NumberValidator.php b/src/Validation/Validators/NumberValidator.php new file mode 100644 index 0000000..a08aa52 --- /dev/null +++ b/src/Validation/Validators/NumberValidator.php @@ -0,0 +1,16 @@ +validator->isNumber($number); + } +} diff --git a/src/Validation/Validators/StringValidator.php b/src/Validation/Validators/StringValidator.php new file mode 100644 index 0000000..570b7d0 --- /dev/null +++ b/src/Validation/Validators/StringValidator.php @@ -0,0 +1,16 @@ +validator->isString($value); + } +} diff --git a/src/Validation/Validators/TextDirectionValidator.php b/src/Validation/Validators/TextDirectionValidator.php new file mode 100644 index 0000000..0d02a40 --- /dev/null +++ b/src/Validation/Validators/TextDirectionValidator.php @@ -0,0 +1,16 @@ +validator->isTextDirection($direction); + } +} diff --git a/src/Validation/Validators/TextTransformValidator.php b/src/Validation/Validators/TextTransformValidator.php new file mode 100644 index 0000000..402ea8d --- /dev/null +++ b/src/Validation/Validators/TextTransformValidator.php @@ -0,0 +1,16 @@ +validator->isTextTransform($transform); + } +} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index 16d50b0..23bcf0e 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -2,14 +2,17 @@ namespace MadeByDenis\PhpMjmlRenderer\Tests; +use MadeByDenis\PhpMjmlRenderer\Elements\Element; use MadeByDenis\PhpMjmlRenderer\Parser\MjmlNode; use MadeByDenis\PhpMjmlRenderer\Parser\MjmlParser; use MadeByDenis\PhpMjmlRenderer\Renderer\MjmlRenderer; use PHPUnit\Framework\TestCase; +#[AllowDynamicProperties] 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; } 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 @@ +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'); diff --git a/tests/Unit/Elements/BodyComponents/MjTextTest.php b/tests/Unit/Elements/BodyComponents/MjTextTest.php new file mode 100644 index 0000000..5e5054b --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjTextTest.php @@ -0,0 +1,98 @@ +element = new MjText(); +}); + +it('is ending tag', function () { + expect($this->element->isEndingTag())->toBeTrue(); +}); + +it('returns the correct component name', function () { + expect($this->element->getTagName())->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); + +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!
'); +}); + +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 new file mode 100644 index 0000000..2bf7059 --- /dev/null +++ b/tests/Unit/Elements/ElementFactoryTest.php @@ -0,0 +1,25 @@ +factory = new ElementFactory(); +}); + +it('Will correctly return class of the desired element', function () { + $textNode = new MjmlNode( + 'mj-text', + [], + '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..fae8d63 100644 --- a/tests/Unit/Renderer/RenderTest.php +++ b/tests/Unit/Renderer/RenderTest.php @@ -48,3 +48,31 @@ expect($htmlOut)->toEqual($htmlExpected); })->skip(); + +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); +})->skip(); 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'); +