diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc062e2..b2f0819 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' ] 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' ] 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' ] allowed_failure: [ false ] include: - - php: '8.2' + - php: '8.5' allowed_failure: true steps: - name: "Set up PHP" diff --git a/composer.json b/composer.json index 992065b..69586b7 100644 --- a/composer.json +++ b/composer.json @@ -21,16 +21,16 @@ } ], "require": { - "php": ">=7.4", + "php": ">=8.3", "ext-simplexml": "*" }, "require-dev": { - "squizlabs/php_codesniffer": "^3.7", - "pestphp/pest": "^1.22", - "phpstan/phpstan": "^1.9", + "captainhook/captainhook": "^5", + "pestphp/pest": "^3.8", + "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.13", + "phpstan/phpstan": "^2.1" }, "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/phpcs.xml.dist b/phpcs.xml.dist index 2f94c16..500daab 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -7,6 +7,7 @@ + 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 - 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 + + 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..01aae92 --- /dev/null +++ b/src/Elements/AbstractElement.php @@ -0,0 +1,431 @@ + + */ + protected array $defaultAttributes = []; + + /** + * @var array> + */ + protected array $allowedAttributes = []; + + /** + * @var array + */ + protected array $attributes = []; + + protected array $properties = []; + + protected ?array $children; + + /** + * @var array + */ + protected array $context = []; + protected string $content = ''; + protected ?string $absoluteFilePath = null; + + private 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', + ]; + + public function __construct(?array $attributes = [], string $content = '', ?array $childNodes = []) + { + $this->attributes = $this->formatAttributes( + $this->defaultAttributes, + $this->allowedAttributes, + $attributes, + ); + + $this->content = $content; + $this->children = $childNodes; + } + + 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. + * + */ + public function getAllowedAttributeData(string $attributeName, string $attributeProperty = ''): array | string + { + 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): mixed + { + return $this->attributes[$attributeName] ?? null; + } + + /** + * Return the globally set attributes + * + * @return array + */ + 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); + } + + /** + * @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, + ]; + + // 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 = ''; + + array_walk($nonEmpty, function ($val, $key) use (&$attrOut, $specialAttributes) { + $value = !empty($specialAttributes[$key]) ? + $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\""; + }); + + 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)) { + if (is_array($val)) { + $val = implode(' ', $val); + } + + $styles .= "$key:$val;"; + } + }); + + return trim($styles); + } + + 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; + + 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, + ?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; + } + + 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; + } +} diff --git a/src/Elements/BodyComponents/MjBody.php b/src/Elements/BodyComponents/MjBody.php new file mode 100644 index 0000000..7bfe34f --- /dev/null +++ b/src/Elements/BodyComponents/MjBody.php @@ -0,0 +1,100 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'body background color', + 'default_value' => '#FFFFFF', + ], + 'width' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'The width of the element', + 'default_value' => '600px', + ], + ]; + + protected array $defaultAttributes = [ + 'width' => '600px', + ]; + + public function render(): string + { + $this->setBackgroundColor($this->getAttribute('background-color')); + + // Fetch from globals. + $globalData = $this->getGlobalAttributes(); + + $lang = $globalData['lang']; + $dir = $globalData['dir']; + + $htmlAttributes = $this->getHtmlAttributes([ + 'class' => $this->getAttribute('css-class'), + 'style' => 'div', + $lang, + $dir, + ]); + + $children = $this->getChildren(); + + $content = $this->renderChildren($children); + + 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/MjColumn.php b/src/Elements/BodyComponents/MjColumn.php new file mode 100644 index 0000000..002de2f --- /dev/null +++ b/src/Elements/BodyComponents/MjColumn.php @@ -0,0 +1,468 @@ +> + */ + 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)); + } +} 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() ?? ''; + $children = $node->getChildren() ?? []; + + return new $class($attributes, $content, $children); // 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/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/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/Elements/Helpers/CssClasses.php b/src/Elements/Helpers/CssClasses.php new file mode 100644 index 0000000..d4d9f47 --- /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}>" : + "<{$tagName} {$stringAttrs}>{$subNode}{$tagName}>"; + } +} 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..ffe9e90 --- /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/MjBodyTest.php b/tests/Unit/Elements/BodyComponents/MjBodyTest.php new file mode 100644 index 0000000..31cced0 --- /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 + + + + + + + + + + + + +'); +})->skip(); 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..abb9ae6 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); -})->expectExceptionMessage('simplexml_load_string(): Entity:'); + $this->parser->parse($mjml); +})->expectExceptionMessage('Badly formatted MJML code.'); 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'); +