• 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

98.78
/src/Texy/Modules/HtmlOutputModule.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\Regexp;
12
use function array_intersect, array_keys, array_unshift, is_string, max, reset, rtrim, str_repeat, str_replace, strtr, wordwrap;
13

14

15
/**
16
 * Formats and validates HTML output (well-forming, indentation, line wrapping).
17
 */
18
final class HtmlOutputModule extends Texy\Module
19
{
20
        public const InnerTransparent = '%TRANS';
21
        public const InnerText = '%TEXT';
22

23
        /** @var array<string, 1>  void elements */
24
        public static array $emptyElements = [
25
                'area' => 1, 'base' => 1, 'br' => 1, 'col' => 1, 'embed' => 1, 'hr' => 1, 'img' => 1, 'input' => 1,
26
                'link' => 1, 'meta' => 1, 'param' => 1, 'source' => 1, 'track' => 1, 'wbr' => 1,
27
        ];
28

29
        /** @var array<string, int>  phrasing elements; replaced elements + br have value 1 */
30
        public static array $inlineElements = [
31
                'a' => 0, 'abbr' => 0, 'area' => 0, 'audio' => 0, 'b' => 0, 'bdi' => 0, 'bdo' => 0, 'br' => 1, 'button' => 1, 'canvas' => 1,
32
                'cite' => 0, 'code' => 0, 'data' => 0, 'datalist' => 0, 'del' => 0, 'dfn' => 0, 'em' => 0, 'embed' => 1, 'i' => 0, 'iframe' => 1,
33
                'img' => 1, 'input' => 1, 'ins' => 0, 'kbd' => 0, 'label' => 0, 'link' => 0, 'map' => 0, 'mark' => 0, 'math' => 1, 'meta' => 0,
34
                'meter' => 1, 'noscript' => 1, 'object' => 1, 'output' => 1, 'picture' => 1, 'progress' => 1, 'q' => 0, 'ruby' => 0, 's' => 0,
35
                'samp' => 0, 'script' => 1, 'select' => 1, 'slot' => 0, 'small' => 0, 'span' => 0, 'strong' => 0, 'sub' => 0, 'sup' => 0,
36
                'svg' => 1, 'template' => 0, 'textarea' => 1, 'time' => 0, 'u' => 0, 'var' => 0, 'video' => 1, 'wbr' => 0,
37
        ];
38

39
        /** @var array<string, 1>  elements with optional end tag in HTML */
40
        public static array $optionalEnds = [
41
                'body' => 1, 'head' => 1, 'html' => 1, 'colgroup' => 1, 'dd' => 1, 'dt' => 1, 'li' => 1,
42
                'option' => 1, 'p' => 1, 'tbody' => 1, 'td' => 1, 'tfoot' => 1, 'th' => 1, 'thead' => 1, 'tr' => 1,
43
        ];
44

45
        /** @var array<string, list<string>>  deep element prohibitions */
46
        public static array $prohibits = [
47
                'a' => ['a', 'button'],
48
                'button' => ['a', 'button'],
49
                'form' => ['form'],
50
        ];
51

52
        /**
53
         * Content model for HTML well-forming (simplified).
54
         * @var array<string, array<string, 1>>
55
         */
56
        public static array $contentModel = [
57
                // Tables
58
                'table' => ['caption' => 1, 'colgroup' => 1, 'thead' => 1, 'tbody' => 1, 'tfoot' => 1, 'tr' => 1],
59
                'thead' => ['tr' => 1],
60
                'tbody' => ['tr' => 1],
61
                'tfoot' => ['tr' => 1],
62
                'tr' => ['th' => 1, 'td' => 1],
63
                'colgroup' => ['col' => 1],
64
                // Lists
65
                'ul' => ['li' => 1],
66
                'ol' => ['li' => 1],
67
                'dl' => ['dt' => 1, 'dd' => 1],
68
                // Transparent content model (inherit from parent)
69
                'a' => [self::InnerTransparent => 1],
70
                'ins' => [self::InnerTransparent => 1],
71
                'del' => [self::InnerTransparent => 1],
72
                'figure' => ['figcaption' => 1, self::InnerTransparent => 1],
73
                'fieldset' => ['legend' => 1, self::InnerTransparent => 1],
74
                'object' => ['param' => 1, self::InnerTransparent => 1],
75
                'noscript' => [self::InnerTransparent => 1],
76
                // Text-only
77
                'script' => [self::InnerText => 1],
78
                'style' => [self::InnerText => 1],
79
                'textarea' => [self::InnerText => 1],
80
                // Empty content
81
                'iframe' => [],
82
        ];
83

84
        /** indent HTML code? */
85
        public bool $indent = true;
86

87
        /** @var string[] */
88
        public array $preserveSpaces = ['textarea', 'pre', 'script', 'code', 'samp', 'kbd'];
89

90
        /** base indent level */
91
        public int $baseIndent = 0;
92

93
        /** wrap width, doesn't include indent space */
94
        public int $lineWrap = 80;
95

96
        /** @var array<string, 1>  block elements with phrasing content */
97
        private static array $phrasingElements = [
98
                'p' => 1, 'h1' => 1, 'h2' => 1, 'h3' => 1, 'h4' => 1, 'h5' => 1, 'h6' => 1,
99
                'pre' => 1, 'legend' => 1, 'caption' => 1, 'figcaption' => 1, 'summary' => 1,
100
        ];
101

102
        /** indent space counter */
103
        private int $space = 0;
104

105
        /** @var array<string, int> */
106
        private array $tagUsed = [];
107

108
        /** @var array<int, array{tag: string, open: ?string, close: ?string, dtdContent: array<string, int>, indent: int}> */
109
        private array $tagStack = [];
110

111
        /** @var array<string, int>  content DTD used, when context is not defined */
112
        private array $baseDTD = [];
113

114

115
        public function __construct(Texy\Texy $texy)
1✔
116
        {
117
                $texy->addHandler('postProcess', $this->postProcess(...));
1✔
118
        }
1✔
119

120

121
        /**
122
         * Converts <strong><em> ... </strong> ... </em>.
123
         * into <strong><em> ... </em></strong><em> ... </em>
124
         */
125
        private function postProcess(string &$s): void
1✔
126
        {
127
                $this->space = $this->baseIndent;
1✔
128
                $this->tagStack = [];
1✔
129
                $this->tagUsed = [];
1✔
130

131
                $this->baseDTD = self::getFlowContent();
1✔
132

133
                // wellform and reformat
134
                $s = Regexp::replace(
1✔
135
                        $s . '</end/>',
1✔
136
                        '~
1✔
137
                                ( [^<]*+ )
138
                                < (?: (!--.*--) | (/?) ([a-z][a-z0-9._:-]*) (|[ \n].*) \s* (/?) ) >
139
                        ~Uis',
140
                        $this->processFragment(...),
1✔
141
                );
142

143
                // empty out stack
144
                foreach ($this->tagStack as $item) {
1✔
145
                        $s .= $item['close'];
1✔
146
                }
147

148
                // right trim
149
                $s = Regexp::replace($s, '~[\t ]+(\n|\r|$)~', '$1'); // right trim
1✔
150

151
                // join double \r to single \n
152
                $s = str_replace("\r\r", "\n", $s);
1✔
153
                $s = strtr($s, "\r", "\n");
1✔
154

155
                // greedy chars
156
                $s = Regexp::replace($s, '~\x07\ *~', '');
1✔
157
                // back-tabs
158
                $s = Regexp::replace($s, '~\t?\ *\x08~', '');
1✔
159

160
                // line wrap
161
                if ($this->lineWrap > 0) {
1✔
162
                        $s = Regexp::replace(
1✔
163
                                $s,
1✔
164
                                '~^(\t*)(.*)$~m',
1✔
165
                                $this->wrap(...),
1✔
166
                        );
167
                }
168
        }
1✔
169

170

171
        /**
172
         * Processes a fragment of HTML: text content followed by a tag or comment.
173
         * @param  array<?string>  $matches
174
         */
175
        private function processFragment(array $matches): string
1✔
176
        {
177
                // html tag
178
                /** @var array{string, string, ?string, ?string, ?string, ?string, ?string} $matches */
179
                [, $mText, $mComment, $mEnd, $mTag, $mAttr, $mEmpty] = $matches;
1✔
180
                // [1] => text
181
                // [1] => !-- comment --
182
                // [2] => /
183
                // [3] => TAG
184
                // [4] => ... (attributes)
185
                // [5] => / (empty)
186

187
                $s = '';
1✔
188

189
                // phase #1 - stuff between tags
190
                if ($mText !== '') {
1✔
191
                        $item = reset($this->tagStack);
1✔
192
                        if ($item && !isset($item['dtdContent'][self::InnerText])) {  // text not allowed?
1✔
193

194
                        } elseif (array_intersect(array_keys($this->tagUsed, filter_value: true, strict: false), $this->preserveSpaces)) { // inside pre & textarea preserve spaces
1✔
195
                                $s = Texy\Helpers::freezeSpaces($mText);
1✔
196

197
                        } else {
198
                                $s = Regexp::replace($mText, '~[ \n]+~', ' '); // otherwise shrink multiple spaces
1✔
199
                        }
200
                }
201

202
                // phase #2 - HTML comment
203
                if ($mComment) {
1✔
204
                        return $s . '<' . Texy\Helpers::freezeSpaces($mComment) . '>';
1✔
205
                }
206

207
                // phase #3 - HTML tag
208
                assert(is_string($mTag) && is_string($mAttr));
209
                $mEmpty = $mEmpty || isset(self::$emptyElements[$mTag]);
1✔
210
                if ($mEmpty && $mEnd) { // bad tag; /end/
1✔
211
                        return $s;
1✔
212
                } elseif ($mEnd) {
1✔
213
                        return $s . $this->processEndTag($mTag);
1✔
214
                } else {
215
                        return $this->processStartTag($mTag, $mEmpty, $mAttr, $s);
1✔
216
                }
217
        }
218

219

220
        private function processStartTag(string $tag, bool $empty, string $attr, string $s): string
1✔
221
        {
222
                $dtdContent = $this->baseDTD;
1✔
223

224
                if (!self::isKnownTag($tag)) {
1✔
225
                        // Unknown tags (custom elements) are always allowed and inherit content model
226
                        // from parent - they act as transparent wrappers that don't restrict children
227
                        $allowed = true;
1✔
228
                        $item = reset($this->tagStack);
1✔
229
                        if ($item) {
1✔
230
                                $dtdContent = $item['dtdContent'];
1✔
231
                        }
232
                } else {
233
                        // Known HTML tags must respect content model rules
234
                        $s .= $this->closeOptionalTags($tag, $dtdContent);
1✔
235
                        $allowed = isset($dtdContent[$tag]);
1✔
236

237
                        // Deep prohibitions: certain elements cannot be nested anywhere inside others
238
                        // (e.g., <a> cannot contain <a> or <button> at any depth)
239
                        if ($allowed && isset(self::$prohibits[$tag])) {
1✔
240
                                foreach (self::$prohibits[$tag] as $pTag) {
1✔
241
                                        if (!empty($this->tagUsed[$pTag])) {
1✔
242
                                                $allowed = false;
1✔
243
                                                break;
1✔
244
                                        }
245
                                }
246
                        }
247
                }
248

249
                // Void elements don't go on stack - they have no closing tag
250
                if ($empty) {
1✔
251
                        if (!$allowed) {
1✔
UNCOV
252
                                return $s;
×
253
                        }
254

255
                        $indent = $this->indent && !array_intersect(array_keys($this->tagUsed, filter_value: true, strict: false), $this->preserveSpaces);
1✔
256

257
                        if ($indent && $tag === 'br') {
1✔
258
                                return rtrim($s) . '<' . $tag . $attr . ">\n" . str_repeat("\t", max(0, $this->space - 1)) . "\x07";
1✔
259

260
                        } elseif ($indent && !isset(self::$inlineElements[$tag])) {
1✔
261
                                $space = "\r" . str_repeat("\t", $this->space);
1✔
262
                                return $s . $space . '<' . $tag . $attr . '>' . $space;
1✔
263

264
                        } else {
265
                                return $s . '<' . $tag . $attr . '>';
1✔
266
                        }
267
                }
268

269
                $open = null;
1✔
270
                $close = null;
1✔
271
                $indent = 0;
1✔
272

273
                if ($allowed) {
1✔
274
                        $open = '<' . $tag . $attr . '>';
1✔
275

276
                        // Determine what children this tag can contain
277
                        $dtdContent = self::isKnownTag($tag)
1✔
278
                                ? $this->getChildContent($tag, $dtdContent)
1✔
279
                                : $dtdContent; // unknown tags keep inherited content model
1✔
280

281
                        // Format output with indentation for block elements
282
                        if ($this->indent && !isset(self::$inlineElements[$tag])) {
1✔
283
                                $close = "\x08" . '</' . $tag . '>' . "\n" . str_repeat("\t", $this->space);
1✔
284
                                $s .= "\n" . str_repeat("\t", $this->space++) . $open . "\x07";
1✔
285
                                $indent = 1;
1✔
286
                        } else {
287
                                $close = '</' . $tag . '>';
1✔
288
                                $s .= $open;
1✔
289
                        }
290
                }
291

292
                // Push tag to stack for tracking open elements
293
                $item = [
1✔
294
                        'tag' => $tag,
1✔
295
                        'open' => $open,
1✔
296
                        'close' => $close,
1✔
297
                        'dtdContent' => $dtdContent,
1✔
298
                        'indent' => $indent,
1✔
299
                ];
300
                array_unshift($this->tagStack, $item);
1✔
301
                $tmp = &$this->tagUsed[$tag];
1✔
302
                $tmp++;
1✔
303

304
                return $s;
1✔
305
        }
306

307

308
        /**
309
         * Determines what content (child elements) a known tag can contain.
310
         * @param  array<string, int>  $parentContent
311
         * @return array<string, int>
312
         */
313
        private function getChildContent(string $tag, array $parentContent): array
1✔
314
        {
315
                $tagContent = self::$contentModel[$tag] ?? null;
1✔
316

317
                if ($tagContent !== null) {
1✔
318
                        if (isset($tagContent[self::InnerTransparent])) {
1✔
319
                                // Transparent: inherits parent's content model plus its own additions
320
                                $parentContent += $tagContent;
1✔
321
                                unset($parentContent[self::InnerTransparent]);
1✔
322
                                return $parentContent;
1✔
323
                        }
324
                        if ($tagContent === []) {
1✔
325
                                // Empty content model (e.g., iframe) - no children allowed
326
                                return [];
1✔
327
                        }
328
                        // Explicit content model (e.g., table, ul)
329
                        return $tagContent + [self::InnerText => 1];
1✔
330
                }
331

332
                if (isset(self::$inlineElements[$tag]) || isset(self::$phrasingElements[$tag])) {
1✔
333
                        // Phrasing content only (e.g., <p>, <span>)
334
                        return self::getPhrasingContent();
1✔
335
                }
336

337
                // Block element - allows flow content (e.g., <div>)
338
                return self::getFlowContent();
1✔
339
        }
340

341

342
        private function processEndTag(string $tag): string
1✔
343
        {
344
                // has start tag?
345
                if (empty($this->tagUsed[$tag])) {
1✔
346
                        return '';
1✔
347
                }
348

349
                // autoclose tags
350
                $tmp = [];
1✔
351
                $back = true;
1✔
352
                $s = '';
1✔
353
                foreach ($this->tagStack as $i => $item) {
1✔
354
                        $itemTag = $item['tag'];
1✔
355
                        $s .= $item['close'];
1✔
356
                        $this->space -= $item['indent'];
1✔
357
                        $this->tagUsed[$itemTag]--;
1✔
358
                        $back = $back && isset(self::$inlineElements[$itemTag]);
1✔
359
                        unset($this->tagStack[$i]);
1✔
360
                        if ($itemTag === $tag) {
1✔
361
                                break;
1✔
362
                        }
363

364
                        array_unshift($tmp, $item);
1✔
365
                }
366

367
                if (!$back || !$tmp) {
1✔
368
                        return $s;
1✔
369
                }
370

371
                // allowed-check (nejspis neni ani potreba)
372
                $item = reset($this->tagStack);
1✔
373
                $dtdContent = $item ? $item['dtdContent'] : $this->baseDTD;
1✔
374
                if (!isset($dtdContent[$tmp[0]['tag']])) {
1✔
UNCOV
375
                        return $s;
×
376
                }
377

378
                // autoopen tags
379
                foreach ($tmp as $item) {
1✔
380
                        $s .= $item['open'];
1✔
381
                        $this->space += $item['indent'];
1✔
382
                        $this->tagUsed[$item['tag']]++;
1✔
383
                        array_unshift($this->tagStack, $item);
1✔
384
                }
385

386
                return $s;
1✔
387
        }
388

389

390
        /** @param  array<string, int>  $dtdContent */
391
        private function closeOptionalTags(string $tag, array &$dtdContent): string
1✔
392
        {
393
                $s = '';
1✔
394
                foreach ($this->tagStack as $i => $item) {
1✔
395
                        // is tag allowed here?
396
                        $dtdContent = $item['dtdContent'];
1✔
397
                        if (isset($dtdContent[$tag])) {
1✔
398
                                break;
1✔
399
                        }
400

401
                        $itemTag = $item['tag'];
1✔
402

403
                        // auto-close hidden, optional and inline tags
404
                        if (
405
                                $item['close']
1✔
406
                                && (
407
                                        !isset(self::$optionalEnds[$itemTag])
1✔
408
                                        && !isset(self::$inlineElements[$itemTag])
1✔
409
                                )
410
                        ) {
411
                                break;
1✔
412
                        }
413

414
                        // close it
415
                        $s .= $item['close'];
1✔
416
                        $this->space -= $item['indent'];
1✔
417
                        $this->tagUsed[$itemTag]--;
1✔
418
                        unset($this->tagStack[$i]);
1✔
419
                        $dtdContent = $this->baseDTD;
1✔
420
                }
421

422
                return $s;
1✔
423
        }
424

425

426
        /**
427
         * Callback function: wrap lines.
428
         * @param  array<?string>  $m
429
         */
430
        private function wrap(array $m): string
1✔
431
        {
432
                /** @var array{string, string, string} $m */
433
                [, $space, $s] = $m;
1✔
434
                return $space . wordwrap($s, $this->lineWrap, "\n" . $space);
1✔
435
        }
436

437

438
        private static function isKnownTag(string $tag): bool
1✔
439
        {
440
                return isset(self::$inlineElements[$tag])
1✔
441
                        || isset(self::$emptyElements[$tag])
1✔
442
                        || isset(self::$optionalEnds[$tag])
1✔
443
                        || isset(self::$contentModel[$tag])
1✔
444
                        || isset(self::$prohibits[$tag])
1✔
445
                        || isset(self::$phrasingElements[$tag])
1✔
446
                        || isset(self::getFlowContent()[$tag]);
1✔
447
        }
448

449

450
        /** @return array<string, 1> */
451
        private static function getFlowContent(): array
452
        {
453
                static $content;
1✔
454
                return $content ??= self::$inlineElements
1✔
455
                        + ['div' => 1, 'p' => 1, 'ul' => 1, 'ol' => 1, 'dl' => 1, 'table' => 1,
1✔
456
                                'blockquote' => 1, 'pre' => 1, 'figure' => 1, 'hr' => 1, 'address' => 1,
457
                                'h1' => 1, 'h2' => 1, 'h3' => 1, 'h4' => 1, 'h5' => 1, 'h6' => 1,
458
                                'header' => 1, 'footer' => 1, 'main' => 1, 'article' => 1, 'section' => 1, 'nav' => 1, 'aside' => 1,
459
                                'form' => 1, 'fieldset' => 1, 'html' => 1, 'head' => 1, 'body' => 1, self::InnerText => 1];
460
        }
461

462

463
        /** @return array<string, 1> */
464
        private static function getPhrasingContent(): array
465
        {
466
                static $content;
1✔
467
                return $content ??= self::$inlineElements + [self::InnerText => 1];
1✔
468
        }
469
}
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