diff --git a/src/Elements/BodyComponents/MjSocial.php b/src/Elements/BodyComponents/MjSocial.php new file mode 100644 index 0000000..eb6eebb --- /dev/null +++ b/src/Elements/BodyComponents/MjSocial.php @@ -0,0 +1,63 @@ + ['unit' => 'string', 'type' => 'alignment', 'default_value' => 'center'], + 'border-radius' => ['unit' => 'px', 'type' => 'string', 'default_value' => '3px'], + 'color' => ['unit' => 'color', 'type' => 'color', 'default_value' => '#333333'], + 'font-family' => [ + 'unit' => 'string', + 'type' => 'string', + 'default_value' => 'Ubuntu, Helvetica, Arial, sans-serif', + ], + 'font-size' => ['unit' => 'px', 'type' => 'string', 'default_value' => '13px'], + 'icon-size' => ['unit' => 'px', 'type' => 'string', 'default_value' => '20px'], + 'mode' => ['unit' => 'string', 'type' => 'string', 'default_value' => 'horizontal'], + 'padding' => ['unit' => 'px', 'type' => 'string', 'default_value' => '10px 25px'], + ]; + + protected array $defaultAttributes = [ + 'align' => 'center', + 'border-radius' => '3px', + 'color' => '#333333', + 'font-size' => '13px', + 'icon-size' => '20px', + 'mode' => 'horizontal', + 'padding' => '10px 25px', + ]; + + public function render(): string + { + $children = $this->getChildren() ?? []; + $content = $this->renderChildren($children, []); + $divAttributes = $this->getHtmlAttributes(['style' => 'div']); + return "
$content
"; + } + + public function getChildContext(): array + { + return [ + ...$this->context, + 'border-radius' => $this->getAttribute('border-radius'), + 'color' => $this->getAttribute('color'), + 'font-family' => $this->getAttribute('font-family'), + 'font-size' => $this->getAttribute('font-size'), + 'icon-size' => $this->getAttribute('icon-size'), + ]; + } + + public function getStyles(): array + { + return ['div' => ['text-align' => $this->getAttribute('align')]]; + } +} diff --git a/src/Elements/BodyComponents/MjSocialElement.php b/src/Elements/BodyComponents/MjSocialElement.php new file mode 100644 index 0000000..621609a --- /dev/null +++ b/src/Elements/BodyComponents/MjSocialElement.php @@ -0,0 +1,62 @@ + ['unit' => 'string', 'type' => 'alignment', 'default_value' => 'left'], + 'background-color' => ['unit' => 'color', 'type' => 'color', 'default_value' => ''], + 'border-radius' => ['unit' => 'px', 'type' => 'string', 'default_value' => ''], + 'color' => ['unit' => 'color', 'type' => 'color', 'default_value' => ''], + 'font-family' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'font-size' => ['unit' => 'px', 'type' => 'string', 'default_value' => ''], + 'href' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'icon-size' => ['unit' => 'px', 'type' => 'string', 'default_value' => ''], + 'name' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'padding' => ['unit' => 'px', 'type' => 'string', 'default_value' => '4px'], + 'src' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'target' => ['unit' => 'string', 'type' => 'string', 'default_value' => '_blank'], + ]; + + protected array $defaultAttributes = [ + 'align' => 'left', + 'padding' => '4px', + 'target' => '_blank', + ]; + + public function render(): string + { + $href = $this->getAttribute('href'); + $target = $this->getAttribute('target'); + $src = $this->getAttribute('src'); + $iconSize = $this->getAttribute('icon-size') ?: $this->context['icon-size'] ?? '20px'; + $content = $this->getContent(); + + $aAttributes = $this->getHtmlAttributes(['style' => 'a']); + $icon = $src ? "" : ''; + + return "$icon $content"; + } + + public function getStyles(): array + { + $color = $this->getAttribute('color') ?: $this->context['color'] ?? '#333333'; + $fontFamily = $this->getAttribute('font-family') ?: + $this->context['font-family'] ?? 'Ubuntu, Helvetica, Arial, sans-serif'; + + return ['a' => [ + 'color' => $color, + 'font-family' => $fontFamily, + 'padding' => $this->getAttribute('padding'), + 'text-decoration' => 'none', + ]]; + } +} diff --git a/src/Elements/HeadComponents/MjAttributes.php b/src/Elements/HeadComponents/MjAttributes.php new file mode 100644 index 0000000..f062560 --- /dev/null +++ b/src/Elements/HeadComponents/MjAttributes.php @@ -0,0 +1,26 @@ +> - */ protected array $allowedAttributes = [ - 'width' => [ - 'unit' => 'px', - 'type' => 'measure', - 'description' => 'breakpoint width', - 'default_value' => '480px', - ], + 'width' => ['unit' => 'px', 'type' => 'string', 'default_value' => '480px'], ]; protected array $defaultAttributes = [ @@ -47,14 +21,9 @@ class MjBreakpoint extends AbstractElement public function render(): string { - // mj-breakpoint doesn't render any HTML - // It's processed by mj-head to set responsive breakpoint return ''; } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/HeadComponents/MjFont.php b/src/Elements/HeadComponents/MjFont.php index 713ce37..a33077c 100644 --- a/src/Elements/HeadComponents/MjFont.php +++ b/src/Elements/HeadComponents/MjFont.php @@ -1,70 +1,35 @@ > - */ protected array $allowedAttributes = [ - 'name' => [ - 'unit' => 'string', - 'type' => 'string', - 'description' => 'font name', - 'default_value' => '', - ], - 'href' => [ - 'unit' => 'string', - 'type' => 'string', - 'description' => 'font url', - 'default_value' => '', - ], + 'name' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'href' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], ]; protected array $defaultAttributes = []; public function render(): string { - $name = $this->getAttribute('name'); $href = $this->getAttribute('href'); + $name = $this->getAttribute('name'); if (!$href) { return ''; } - // Render as a link tag for non-MSO clients - return ""; + return ""; } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/HeadComponents/MjHead.php b/src/Elements/HeadComponents/MjHead.php index 7f7426d..9268c0f 100644 --- a/src/Elements/HeadComponents/MjHead.php +++ b/src/Elements/HeadComponents/MjHead.php @@ -1,153 +1,25 @@ > - */ protected array $allowedAttributes = []; - protected array $defaultAttributes = []; public function render(): string { $children = $this->getChildren() ?? []; - - $title = ''; - $preview = ''; - $styles = ''; - $fonts = []; - $breakpoint = '480px'; - - // Process head components - foreach ($children as $child) { - $tag = $child->getTag(); - $element = ElementFactory::create($child); - - match ($tag) { - 'mj-title' => $title = $element->render(), - 'mj-preview' => $preview = $element->render(), - 'mj-style' => $styles .= $element->render(), - 'mj-font' => $fonts[] = $element->render(), - 'mj-breakpoint' => $breakpoint = $child->getAttributeValue('width') ?: '480px', - default => null, - }; - } - - // Build head content - $head = ''; - $head .= $title ?: ''; - $head .= ''; - $head .= ''; - $head .= ''; - $head .= $this->renderBaseStyles(); - $head .= $this->renderMsoStyles(); - $head .= $this->renderOutlookStyles(); - - // Add fonts - foreach ($fonts as $font) { - $head .= $font; - } - - // Add responsive styles - $head .= $this->renderResponsiveStyles($breakpoint); - - // Add custom styles - $head .= $styles; - - // Add preview - $head .= $preview; - - $head .= ''; - - return $head; - } - - private function renderBaseStyles(): string - { - $styles = ''; - - return $styles; - } - - private function renderMsoStyles(): string - { - $msoStyles = ''; - - return $msoStyles; - } - - private function renderOutlookStyles(): string - { - return ''; - } - - private function renderResponsiveStyles(string $breakpoint): string - { - $minWidth = $breakpoint; - - $styles = ''; - $styles .= '@media only screen and (min-width:$minWidth) {"; - $styles .= ' .mj-column-per-100 { width:100% !important; max-width: 100%; }'; - $styles .= ' }'; - $styles .= "'; - - return $styles; + return $this->renderChildren($children, []); } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/HeadComponents/MjHtmlAttributes.php b/src/Elements/HeadComponents/MjHtmlAttributes.php new file mode 100644 index 0000000..2324ba1 --- /dev/null +++ b/src/Elements/HeadComponents/MjHtmlAttributes.php @@ -0,0 +1,26 @@ +> - */ protected array $allowedAttributes = []; - protected array $defaultAttributes = []; public function render(): string { $content = $this->getContent(); - - // Preview text is hidden but available for email clients - $previewStyle = 'display:none;font-size:1px;color:#ffffff;line-height:1px;'; - $previewStyle .= 'max-height:0px;max-width:0px;opacity:0;overflow:hidden;'; - - return "
$content
"; + return "
$content
"; } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/HeadComponents/MjStyle.php b/src/Elements/HeadComponents/MjStyle.php index 9ba34c7..7339644 100644 --- a/src/Elements/HeadComponents/MjStyle.php +++ b/src/Elements/HeadComponents/MjStyle.php @@ -1,44 +1,18 @@ > - */ protected array $allowedAttributes = [ - 'inline' => [ - 'unit' => 'string', - 'type' => 'string', - 'description' => 'whether styles should be inlined', - 'default_value' => '', - ], + 'inline' => ['unit' => 'string', 'type' => 'string', 'default_value' => ''], ]; protected array $defaultAttributes = []; @@ -48,14 +22,13 @@ public function render(): string $content = $this->getContent(); $inline = $this->getAttribute('inline'); - // For now, we'll always render as a style tag in head - // Inline styles would require processing during body rendering - return ""; + if ($inline === 'inline') { + return $content; + } + + return ""; } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/HeadComponents/MjTitle.php b/src/Elements/HeadComponents/MjTitle.php index a00220d..640623a 100644 --- a/src/Elements/HeadComponents/MjTitle.php +++ b/src/Elements/HeadComponents/MjTitle.php @@ -1,51 +1,24 @@ > - */ protected array $allowedAttributes = []; - protected array $defaultAttributes = []; public function render(): string { - $content = $this->getContent(); - - return "$content"; + return '' . $this->getContent() . ''; } - /** - * @return array> - */ public function getStyles(): array { return []; diff --git a/src/Elements/Utilities/MjInclude.php b/src/Elements/Utilities/MjInclude.php new file mode 100644 index 0000000..485b6a5 --- /dev/null +++ b/src/Elements/Utilities/MjInclude.php @@ -0,0 +1,53 @@ + ['unit' => 'string', 'type' => 'string', 'default_value' => ''], + 'type' => ['unit' => 'string', 'type' => 'string', 'default_value' => 'mjml'], + ]; + + protected array $defaultAttributes = [ + 'type' => 'mjml', + ]; + + public function render(): string + { + $path = $this->getAttribute('path'); + $type = $this->getAttribute('type'); + + if (!$path || !file_exists($path)) { + return ''; + } + + $content = file_get_contents($path); + + if ($content === false) { + return ''; + } + + if ($type === 'css') { + return ""; + } + + if ($type === 'html') { + return $content; + } + + return $content; + } + + public function getStyles(): array + { + return []; + } +} diff --git a/tests/Unit/Elements/BodyComponents/MjSocialTest.php b/tests/Unit/Elements/BodyComponents/MjSocialTest.php new file mode 100644 index 0000000..dfac8bd --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjSocialTest.php @@ -0,0 +1,19 @@ + expect((new MjSocial())->getTagName())->toBe('mj-social')); +it('MjSocial has correct defaults', fn() => expect((new MjSocial())->getAttribute('mode'))->toBe('horizontal')); + +it('MjSocialElement has correct tag name', function () { + expect((new MjSocialElement())->getTagName())->toBe('mj-social-element'); +}); + +it('MjSocialElement renders', function () { + $element = new MjSocialElement(['href' => 'https://facebook.com'], 'Facebook'); + $out = $element->render(); + expect($out)->toContain('toContain('https://facebook.com')->toContain('Facebook'); +}); diff --git a/tests/Unit/Elements/HeadComponents/MjHeadComponentsTest.php b/tests/Unit/Elements/HeadComponents/MjHeadComponentsTest.php new file mode 100644 index 0000000..3bafde7 --- /dev/null +++ b/tests/Unit/Elements/HeadComponents/MjHeadComponentsTest.php @@ -0,0 +1,101 @@ + expect((new MjHead())->getTagName())->toBe('mj-head')); + it('is not ending tag', fn() => expect((new MjHead())->isEndingTag())->toBe(false)); +}); + +describe('MjTitle', function () { + it('has correct tag name', fn() => expect((new MjTitle())->getTagName())->toBe('mj-title')); + it('is ending tag', fn() => expect((new MjTitle())->isEndingTag())->toBe(true)); + + it('renders title correctly', function () { + $element = new MjTitle([], 'My Email Title'); + expect($element->render())->toBe('My Email Title'); + }); +}); + +describe('MjPreview', function () { + it('has correct tag name', fn() => expect((new MjPreview())->getTagName())->toBe('mj-preview')); + it('is ending tag', fn() => expect((new MjPreview())->isEndingTag())->toBe(true)); + + it('renders preview text as hidden div', function () { + $element = new MjPreview([], 'Preview text here'); + $out = $element->render(); + expect($out)->toContain('display:none'); + expect($out)->toContain('Preview text here'); + }); +}); + +describe('MjFont', function () { + it('has correct tag name', fn() => expect((new MjFont())->getTagName())->toBe('mj-font')); + + it('renders font link correctly', function () { + $element = new MjFont([ + 'name' => 'Roboto', + 'href' => 'https://fonts.googleapis.com/css?family=Roboto', + ]); + $out = $element->render(); + expect($out)->toContain('toContain('https://fonts.googleapis.com/css?family=Roboto'); + expect($out)->toContain("rel='stylesheet'"); + }); + + it('returns empty string when no href', function () { + $element = new MjFont(['name' => 'Roboto']); + expect($element->render())->toBe(''); + }); +}); + +describe('MjStyle', function () { + it('has correct tag name', fn() => expect((new MjStyle())->getTagName())->toBe('mj-style')); + + it('renders style tag by default', function () { + $element = new MjStyle([], '.test { color: red; }'); + $out = $element->render(); + expect($out)->toContain('toContain('.test { color: red; }'); + }); + + it('renders inline when inline attribute is set', function () { + $element = new MjStyle(['inline' => 'inline'], '.test { color: blue; }'); + expect($element->render())->toBe('.test { color: blue; }'); + }); +}); + +describe('MjBreakpoint', function () { + it('has correct tag name', fn() => expect((new MjBreakpoint())->getTagName())->toBe('mj-breakpoint')); + + it('has default width', function () { + $element = new MjBreakpoint(); + expect($element->getAttribute('width'))->toBe('480px'); + }); + + it('can set custom width', function () { + $element = new MjBreakpoint(['width' => '600px']); + expect($element->getAttribute('width'))->toBe('600px'); + }); +}); + +describe('MjAttributes', function () { + it('has correct tag name', fn() => expect((new MjAttributes())->getTagName())->toBe('mj-attributes')); + it('renders empty', fn() => expect((new MjAttributes())->render())->toBe('')); +}); + +describe('MjHtmlAttributes', function () { + it('has correct tag name', function () { + expect((new MjHtmlAttributes())->getTagName())->toBe('mj-html-attributes'); + }); + it('renders empty', fn() => expect((new MjHtmlAttributes())->render())->toBe('')); +}); diff --git a/tests/Unit/Elements/Utilities/MjIncludeTest.php b/tests/Unit/Elements/Utilities/MjIncludeTest.php new file mode 100644 index 0000000..8a3fd65 --- /dev/null +++ b/tests/Unit/Elements/Utilities/MjIncludeTest.php @@ -0,0 +1,19 @@ + expect((new MjInclude())->getTagName())->toBe('mj-include')); + + it('has default type mjml', function () { + $element = new MjInclude(); + expect($element->getAttribute('type'))->toBe('mjml'); + }); + + it('returns empty string for non-existent file', function () { + $element = new MjInclude(['path' => '/non/existent/file.mjml']); + expect($element->render())->toBe(''); + }); +});