Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
32 / 34
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Html
94.12% covered (success)
94.12%
32 / 34
83.33% covered (warning)
83.33%
5 / 6
27.15
0.00% covered (danger)
0.00%
0 / 1
 subject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 text
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 document
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 node
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
16.50
 link
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Mail;
6
7use Dom\Element;
8use Dom\HTMLDocument;
9use Dom\Node;
10use Dom\Text;
11
12/**
13 * Reads what a mail needs from its rendered HTML: the subject from the
14 * `<title>` element and a plain text version for the text part.
15 *
16 * @internal
17 */
18final class Html
19{
20    private const array BLOCKS = [
21        'address',
22        'article',
23        'aside',
24        'blockquote',
25        'dd',
26        'div',
27        'dl',
28        'dt',
29        'figcaption',
30        'figure',
31        'footer',
32        'h1',
33        'h2',
34        'h3',
35        'h4',
36        'h5',
37        'h6',
38        'header',
39        'hr',
40        'main',
41        'nav',
42        'ol',
43        'p',
44        'pre',
45        'section',
46        'table',
47        'ul',
48    ];
49
50    private const array HIDDEN = ['head', 'script', 'style', 'template', 'title'];
51
52    public static function subject(string $html): string
53    {
54        return self::document($html)->title;
55    }
56
57    public static function text(string $html): string
58    {
59        $document = self::document($html);
60        $text = self::children($document->body ?? $document, false);
61        $lines = array_map(
62            static fn(string $line): string => rtrim($line),
63            explode("\n", $text),
64        );
65
66        return trim((string) preg_replace("/\n{3,}/", "\n\n", implode("\n", $lines)));
67    }
68
69    private static function document(string $html): HTMLDocument
70    {
71        return HTMLDocument::createFromString($html, LIBXML_NOERROR, 'UTF-8');
72    }
73
74    private static function children(Node $node, bool $pre): string
75    {
76        $text = '';
77
78        foreach ($node->childNodes as $child) {
79            $text .= self::node($child, $pre);
80        }
81
82        return $text;
83    }
84
85    private static function node(Node $node, bool $pre): string
86    {
87        if ($node instanceof Text) {
88            return $pre ? $node->data : (string) preg_replace('/\s+/u', ' ', $node->data);
89        }
90
91        if (!$node instanceof Element) {
92            return '';
93        }
94
95        $name = strtolower($node->localName);
96
97        if (in_array($name, self::HIDDEN, true)) {
98            return '';
99        }
100
101        $inner = self::children($node, $pre || $name === 'pre');
102
103        return match (true) {
104            $name === 'br' => "\n",
105            $name === 'img' => (string) $node->getAttribute('alt'),
106            $name === 'a' => self::link($inner, (string) $node->getAttribute('href')),
107            $name === 'li' => "\n- " . trim($inner),
108            $name === 'tr' => "\n" . trim($inner),
109            $name === 'td' || $name === 'th' => trim($inner) . ' ',
110            in_array($name, self::BLOCKS, true) => "\n\n" . ($name === 'pre' ? $inner : trim($inner)) . "\n\n",
111            default => $inner,
112        };
113    }
114
115    private static function link(string $text, string $href): string
116    {
117        $label = trim($text);
118        $target = str_starts_with($href, 'mailto:') ? substr($href, 7) : $href;
119
120        if ($target === '' || str_starts_with($target, '#') || $target === $label) {
121            return $text;
122        }
123
124        return $label === '' ? $target : "{$label} ({$target})";
125    }
126}