• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / texy / 22283286087

22 Feb 2026 06:58PM UTC coverage: 93.01% (+0.02%) from 92.991%
22283286087

push

github

dg
LinkModule: deprecated label and modifiers in link definitions

3 of 3 new or added lines in 1 file covered. (100.0%)

72 existing lines in 16 files now uncovered.

2089 of 2246 relevant lines covered (93.01%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.21
/src/Texy/Modules/HtmlModule.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\HtmlElement;
12
use Texy\Patterns;
13
use Texy\Regexp;
14
use function array_flip, count, explode, is_array, is_string, str_contains, str_ends_with, strtolower, strtr, substr, trim;
15

16

17
/**
18
 * Processes HTML tags and comments in input text.
19
 */
20
final class HtmlModule extends Texy\Module
21
{
22
        /** pass HTML comments to output? */
23
        public bool $passComment = true;
24

25

26
        public function __construct(
1✔
27
                private Texy\Texy $texy,
28
        ) {
29
                $texy->addHandler('htmlComment', $this->solveComment(...));
1✔
30
                $texy->addHandler('htmlTag', $this->solveTag(...));
1✔
31
        }
1✔
32

33

34
        public function beforeParse(string &$text): void
1✔
35
        {
36
                $this->texy->registerLinePattern(
1✔
37
                        $this->parseTag(...),
1✔
38
                        '~
39
                                < (/?)                          # tag begin
40
                                ([a-z][a-z0-9_:-]{0,50})        # tag name
41
                                (
42
                                        (?:
43
                                                \s++ [a-z0-9_:-]++ |   # attribute name
44
                                                = \s*+ " [^"' . Patterns::MARK . ']*+ " |     # attribute value in double quotes
1✔
45
                                                = \s*+ \' [^\'' . Patterns::MARK . ']*+ \' |  # attribute value in single quotes
1✔
46
                                                = [^\s>' . Patterns::MARK . ']++              # attribute value without quotes
1✔
47
                                        )*
48
                                )
49
                                \s*+
50
                                (/?)                             # self-closing slash
51
                                >
52
                        ~is',
53
                        'html/tag',
1✔
54
                );
55

56
                $this->texy->registerLinePattern(
1✔
57
                        $this->parseComment(...),
1✔
58
                        '~
59
                                <!--
60
                                ( [^' . Patterns::MARK . ']*? )
1✔
61
                                -->
62
                        ~is',
63
                        'html/comment',
1✔
64
                );
65
        }
1✔
66

67

68
        /**
69
         * Parses <!-- comment -->
70
         * @param  array<?string>  $matches
71
         */
72
        public function parseComment(Texy\InlineParser $parser, array $matches): HtmlElement|string|null
1✔
73
        {
74
                [, $mComment] = $matches;
1✔
75
                return $this->texy->invokeAroundHandlers('htmlComment', $parser, [$mComment]);
1✔
76
        }
77

78

79
        /**
80
         * Parses <tag attr="...">
81
         * @param  array<?string>  $matches
82
         */
83
        public function parseTag(Texy\InlineParser $parser, array $matches): ?string
1✔
84
        {
85
                /** @var array{string, string, string, string, string} $matches */
86
                [, $mEnd, $mTag, $mAttr, $mEmpty] = $matches;
1✔
87
                // [1] => /
88
                // [2] => tag
89
                // [3] => attributes
90
                // [4] => /
91

92
                $isStart = $mEnd !== '/';
1✔
93
                $isEmpty = $mEmpty === '/';
1✔
94
                if (!$isEmpty && str_ends_with($mAttr, '/')) { // uvizlo v $mAttr?
1✔
95
                        $mAttr = substr($mAttr, 0, -1);
×
UNCOV
96
                        $isEmpty = true;
×
97
                }
98

99
                // error - can't close empty element
100
                if ($isEmpty && !$isStart) {
1✔
UNCOV
101
                        return null;
×
102
                }
103

104
                // error - end element with atttrs
105
                $mAttr = trim(strtr($mAttr, "\n", ' '));
1✔
106
                if ($mAttr && !$isStart) {
1✔
107
                        return null;
1✔
108
                }
109

110
                $el = new HtmlElement($mTag);
1✔
111
                if ($isStart) {
1✔
112
                        $el->attrs = $this->parseAttributes($mAttr);
1✔
113
                }
114

115
                $res = $this->texy->invokeAroundHandlers('htmlTag', $parser, [$el, $isStart, $isEmpty]);
1✔
116

117
                if ($res instanceof HtmlElement) {
1✔
118
                        return $this->texy->protect($isStart ? $res->startTag() : $res->endTag(), $res->getContentType());
1✔
119
                }
120

121
                return $res;
1✔
122
        }
123

124

125
        /**
126
         * Finish invocation.
127
         */
128
        private function solveTag(
1✔
129
                Texy\HandlerInvocation $invocation,
130
                HtmlElement $el,
131
                bool $isStart,
132
                ?bool $forceEmpty = null,
133
        ): ?HtmlElement
134
        {
135
                $texy = $this->texy;
1✔
136

137
                // tag & attibutes
138
                $allowedTags = $texy->allowedTags; // speed-up
1✔
139
                if (!$allowedTags) {
1✔
140
                        return null; // all tags are disabled
1✔
141
                }
142

143
                $name = $el->getName();
1✔
144
                assert($name !== null);
145
                $name = strtolower($name);
1✔
146
                $el->setName($name);
1✔
147

148
                if (is_array($allowedTags)) {
1✔
149
                        if (!isset($allowedTags[$name])) {
1✔
150
                                return null;
1✔
151
                        }
152
                } else { // allowedTags === Texy\Texy::ALL
153
                        if ($forceEmpty) {
1✔
154
                                $el->setName($name, empty: true);
1✔
155
                        }
156
                }
157

158
                // end tag? we are finished
159
                if (!$isStart) {
1✔
160
                        return $el;
1✔
161
                }
162

163
                $this->applyAttrs($el->attrs, is_array($allowedTags) ? $allowedTags[$name] : $texy::ALL);
1✔
164
                $this->applyClasses($el->attrs, $texy->getAllowedProps()[0]);
1✔
165
                $this->applyStyles($el->attrs, $texy->getAllowedProps()[1]);
1✔
166
                if (!$this->validateAttrs($el, $texy)) {
1✔
167
                        return null;
1✔
168
                }
169

170
                return $el;
1✔
171
        }
172

173

174
        /**
175
         * Finish invocation.
176
         */
177
        private function solveComment(Texy\HandlerInvocation $invocation, string $content): string
1✔
178
        {
179
                if (!$this->passComment) {
1✔
UNCOV
180
                        return '';
×
181
                }
182

183
                // sanitize comment
184
                $content = Regexp::replace($content, '~-{2,}~', ' - ');
1✔
185
                $content = trim($content, '-');
1✔
186

187
                return $this->texy->protect('<!--' . $content . '-->', Texy\Texy::CONTENT_MARKUP);
1✔
188
        }
189

190

191
        /**
192
         * @param  array<string, array<string|int|bool>|string|int|bool|null>  $attrs
193
         * @param  bool|string[]  $allowedAttrs
194
         */
195
        private function applyAttrs(array &$attrs, bool|array $allowedAttrs): void
1✔
196
        {
197
                if (!$allowedAttrs) {
1✔
198
                        $attrs = [];
1✔
199

200
                } elseif (is_array($allowedAttrs)) {
1✔
201
                        // skip disabled
202
                        $allowedAttrs = array_flip($allowedAttrs);
1✔
203
                        foreach ($attrs as $key => $foo) {
1✔
204
                                if (!isset($allowedAttrs[$key])) {
1✔
UNCOV
205
                                        unset($attrs[$key]);
×
206
                                }
207
                        }
208
                }
209
        }
1✔
210

211

212
        /**
213
         * @param  array<string, string|int|bool|array<string|int|bool>|null>  $attrs
214
         * @param  array<string, int>|bool  $allowedClasses
215
         */
216
        private function applyClasses(array &$attrs, bool|array $allowedClasses): void
1✔
217
        {
218
                if (!isset($attrs['class'])) {
1✔
219
                } elseif (is_array($allowedClasses)) {
1✔
220
                        $attrs['class'] = is_string($attrs['class']) ? explode(' ', $attrs['class']) : (array) $attrs['class'];
×
UNCOV
221
                        foreach ($attrs['class'] as $key => $value) {
×
UNCOV
222
                                if (!isset($allowedClasses[$value])) {
×
UNCOV
223
                                        unset($attrs['class'][$key]); // id & class are case-sensitive
×
224
                                }
225
                        }
226
                } elseif ($allowedClasses !== Texy\Texy::ALL) {
1✔
UNCOV
227
                        $attrs['class'] = null;
×
228
                }
229

230
                if (!isset($attrs['id'])) {
1✔
231
                } elseif (is_array($allowedClasses)) {
1✔
UNCOV
232
                        if (!is_string($attrs['id']) || !isset($allowedClasses['#' . $attrs['id']])) {
×
UNCOV
233
                                $attrs['id'] = null;
×
234
                        }
235

236
                } elseif ($allowedClasses !== Texy\Texy::ALL) {
1✔
UNCOV
237
                        $attrs['id'] = null;
×
238
                }
239
        }
1✔
240

241

242
        /**
243
         * @param  array<string, string|int|bool|array<string|int|bool>|null>  $attrs
244
         * @param  array<string, int>|bool  $allowedStyles
245
         */
246
        private function applyStyles(array &$attrs, bool|array $allowedStyles): void
1✔
247
        {
248
                if (!isset($attrs['style'])) {
1✔
249
                } elseif (is_array($allowedStyles)) {
1✔
250
                        if (is_string($attrs['style'])) {
×
251
                                $parts = explode(';', $attrs['style']);
×
252
                                $attrs['style'] = [];
×
UNCOV
253
                                foreach ($parts as $value) {
×
UNCOV
254
                                        if (count($pair = explode(':', $value, 2)) === 2) {
×
UNCOV
255
                                                $attrs['style'][trim($pair[0])] = trim($pair[1]);
×
256
                                        }
257
                                }
258
                        } else {
259
                                $attrs['style'] = (array) $attrs['style'];
×
260
                        }
261

UNCOV
262
                        foreach ($attrs['style'] as $key => $value) {
×
UNCOV
263
                                if (!isset($allowedStyles[strtolower((string) $key)])) { // CSS is case-insensitive
×
UNCOV
264
                                        unset($attrs['style'][$key]);
×
265
                                }
266
                        }
267
                } elseif ($allowedStyles !== Texy\Texy::ALL) {
1✔
UNCOV
268
                        $attrs['style'] = null;
×
269
                }
270
        }
1✔
271

272

273
        private function validateAttrs(HtmlElement $el, Texy\Texy $texy): bool
1✔
274
        {
275
                foreach (['src', 'href', 'name', 'id'] as $attr) {
1✔
276
                        if (isset($el->attrs[$attr])) {
1✔
277
                                $el->attrs[$attr] = is_string($el->attrs[$attr])
1✔
278
                                        ? trim($el->attrs[$attr])
1✔
UNCOV
279
                                        : '';
×
280
                                if ($el->attrs[$attr] === '') {
1✔
UNCOV
281
                                        unset($el->attrs[$attr]);
×
282
                                }
283
                        }
284
                }
285

286
                $name = $el->getName();
1✔
287
                if ($name === 'img') {
1✔
288
                        if (!isset($el->attrs['src'])) {
1✔
UNCOV
289
                                return false;
×
290
                        }
291

292
                        assert(is_string($el->attrs['src']));
293
                        if (!$texy->checkURL($el->attrs['src'], $texy::FILTER_IMAGE)) {
1✔
UNCOV
294
                                return false;
×
295
                        }
296

297
                } elseif ($name === 'a') {
1✔
298
                        if (!isset($el->attrs['href']) && !isset($el->attrs['name']) && !isset($el->attrs['id'])) {
1✔
299
                                return false;
1✔
300
                        }
301

302
                        if (isset($el->attrs['href'])) {
1✔
303
                                assert(is_string($el->attrs['href']));
304
                                if ($texy->linkModule->forceNoFollow && str_contains($el->attrs['href'], '//')) {
1✔
305
                                        $el->attrs['rel'] = (array) ($el->attrs['rel'] ?? []);
1✔
306
                                        $el->attrs['rel'][] = 'nofollow';
1✔
307
                                }
308

309
                                if (!$texy->checkURL($el->attrs['href'], $texy::FILTER_ANCHOR)) {
1✔
310
                                        return false;
1✔
311
                                }
312
                        }
313

314
                } elseif (Regexp::match($name ?? '', '~^h[1-6]~i')) {
1✔
315
                        $texy->headingModule->TOC[] = [
1✔
316
                                'el' => $el,
1✔
317
                                'level' => (int) substr($name, 1),
1✔
318
                                'type' => 'html',
1✔
319
                        ];
320
                }
321

322
                return true;
1✔
323
        }
324

325

326
        /** @return array<string, string|bool> */
327
        private function parseAttributes(string $attrs): array
1✔
328
        {
329
                $res = [];
1✔
330
                $matches = Regexp::matchAll(
1✔
331
                        $attrs,
1✔
332
                        <<<'X'
333
                                ~
1✔
334
                                ([a-z0-9_:-]+)                 # attribute name
335
                                \s*
336
                                (?:
337
                                        = \s*                      # equals sign
338
                                        (
339
                                                ' [^']* ' |            # single quoted value
340
                                                " [^"]* " |            # double quoted value
341
                                                [^'"\s]+               # unquoted value
342
                                        )
343
                                )?
344
                                ~is
345
                                X,
346
                );
347

348
                /** @var array{string, string, ?string} $m */
349
                foreach ($matches as $m) {
1✔
350
                        $key = strtolower($m[1]);
1✔
351
                        $value = $m[2];
1✔
352
                        if ($value == null) {
1✔
353
                                $res[$key] = true;
1✔
354
                        } elseif ($value[0] === '\'' || $value[0] === '"') {
1✔
355
                                $res[$key] = Texy\Helpers::unescapeHtml(substr($value, 1, -1));
1✔
356
                        } else {
357
                                $res[$key] = Texy\Helpers::unescapeHtml($value);
1✔
358
                        }
359
                }
360

361
                return $res;
1✔
362
        }
363
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc