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

dg / texy / 20980727294

14 Jan 2026 03:00AM UTC coverage: 92.376%. Remained the same
20980727294

push

github

dg
added CLAUDE.md

2387 of 2584 relevant lines covered (92.38%)

0.92 hits per line

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

79.84
/src/Texy/HtmlElement.php
1
<?php
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
declare(strict_types=1);
9

10
namespace Texy;
11

12
use function array_splice, count, http_build_query, implode, is_array, is_object, is_string, str_replace, str_starts_with;
13

14

15
/**
16
 * HTML helper.
17
 *
18
 * usage:
19
 * $anchor = (new HtmlElement('a'))->href($link)->setText('Texy');
20
 * $el->class = 'myclass';
21
 *
22
 * echo $el->startTag(), $el->endTag();
23
 */
24
class HtmlElement implements \ArrayAccess, /* Countable, */ \IteratorAggregate
25
{
26
        public const InnerText = '%TEXT';
27
        public const InnerTransparent = '%TRANS';
28

29
        /** @var array<string, string|int|bool|string[]|null>  element's attributes */
30
        public array $attrs = [];
31

32
        /** @var array<string, 1>  void elements */
33
        public static array $emptyElements = [
34
                'area' => 1, 'base' => 1, 'br' => 1, 'col' => 1, 'embed' => 1, 'hr' => 1, 'img' => 1, 'input' => 1,
35
                'link' => 1, 'meta' => 1, 'param' => 1, 'source' => 1, 'track' => 1, 'wbr' => 1,
36
        ];
37

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

48
        /** @var array<string, 1>  elements with optional end tag in HTML */
49
        public static array $optionalEnds = [
50
                'body' => 1, 'head' => 1, 'html' => 1, 'colgroup' => 1, 'dd' => 1, 'dt' => 1, 'li' => 1,
51
                'option' => 1, 'p' => 1, 'tbody' => 1, 'td' => 1, 'tfoot' => 1, 'th' => 1, 'thead' => 1, 'tr' => 1,
52
        ];
53

54
        /** @var array<string, list<string>> */
55
        public static array $prohibits = [
56
                'a' => ['a', 'button'],
57
                'button' => ['a', 'button'],
58
                'details' => ['a', 'button'],
59
                'embed' => ['a', 'button'],
60
                'iframe' => ['a', 'button'],
61
                'input' => ['a', 'button'],
62
                'label' => ['a', 'button', 'label'],
63
                'select' => ['a', 'button'],
64
                'textarea' => ['a', 'button'],
65
                'header' => ['address', 'dt', 'footer', 'header', 'th'],
66
                'footer' => ['address', 'dt', 'footer', 'header', 'th'],
67
                'address' => ['address'],
68
                'h1' => ['address', 'dt', 'th'],
69
                'h2' => ['address', 'dt', 'th'],
70
                'h3' => ['address', 'dt', 'th'],
71
                'h4' => ['address', 'dt', 'th'],
72
                'h5' => ['address', 'dt', 'th'],
73
                'h6' => ['address', 'dt', 'th'],
74
                'hgroup' => ['address', 'dt', 'th'],
75
                'article' => ['address', 'dt', 'th'],
76
                'aside' => ['address', 'dt', 'th'],
77
                'nav' => ['address', 'dt', 'th'],
78
                'section' => ['address', 'dt', 'th'],
79
                'table' => ['caption'],
80
                'dfn' => ['dfn'],
81
                'form' => ['form'],
82
                'meter' => ['meter'],
83
                'progress' => ['progress'],
84
        ];
85

86
        /** @var list<HtmlElement|string> nodes */
87
        protected array $children = [];
88
        private ?string $name;
89
        private bool $isEmpty;
90

91

92
        /**
93
         * @param  array|string  $attrs  element's attributes (or textual content)
94
         */
95
        public function __construct(?string $name = null, array|string|null $attrs = null)
1✔
96
        {
97
                $this->setName($name);
1✔
98
                if (is_array($attrs)) {
1✔
99
                        $this->attrs = $attrs;
1✔
100
                } elseif ($attrs !== null) {
1✔
101
                        $this->setText($attrs);
1✔
102
                }
103
        }
1✔
104

105

106
        public static function el(?string $name = null, $attrs = null): static
107
        {
108
                return new self($name, $attrs);
×
109
        }
110

111

112
        /**
113
         * Changes element's name.
114
         */
115
        final public function setName(?string $name, ?bool $empty = null): static
1✔
116
        {
117
                $this->name = $name;
1✔
118
                $this->isEmpty = $empty === null
1✔
119
                        ? isset(self::$emptyElements[$name ?? ''])
1✔
120
                        : (bool) $empty;
×
121
                return $this;
1✔
122
        }
123

124

125
        /**
126
         * Returns element's name.
127
         */
128
        final public function getName(): ?string
129
        {
130
                return $this->name;
1✔
131
        }
132

133

134
        /**
135
         * Is element empty?
136
         */
137
        final public function isEmpty(): bool
138
        {
139
                return $this->isEmpty;
×
140
        }
141

142

143
        /**
144
         * Overloaded setter for element's attribute.
145
         */
146
        final public function __set(string $name, mixed $value): void
147
        {
148
                $this->attrs[$name] = $value;
×
149
        }
150

151

152
        /**
153
         * Overloaded getter for element's attribute.
154
         */
155
        final public function &__get(string $name): mixed
156
        {
157
                return $this->attrs[$name];
×
158
        }
159

160

161
        /**
162
         * Sets element's attribute.
163
         */
164
        final public function setAttribute(string $name, $value): static
165
        {
166
                $this->attrs[$name] = $value;
×
167
                return $this;
×
168
        }
169

170

171
        /**
172
         * Returns element's attribute.
173
         * @return string|int|bool|string[]|null
174
         */
175
        final public function getAttribute(string $name)
176
        {
177
                return $this->attrs[$name] ?? null;
×
178
        }
179

180

181
        /**
182
         * Special setter for element's attribute.
183
         */
184
        final public function href(string $path, ?array $query = null): static
185
        {
186
                if ($query) {
×
187
                        $query = http_build_query($query, '', '&');
×
188
                        if ($query !== '') {
×
189
                                $path .= '?' . $query;
×
190
                        }
191
                }
192

193
                $this->attrs['href'] = $path;
×
194
                return $this;
×
195
        }
196

197

198
        /**
199
         * Sets element's textual content.
200
         */
201
        final public function setText(string $text): static
1✔
202
        {
203
                $this->removeChildren();
1✔
204
                $this->children = [$text];
1✔
205
                return $this;
1✔
206
        }
207

208

209
        /**
210
         * Gets element's textual content.
211
         */
212
        final public function getText(): ?string
213
        {
214
                $s = '';
1✔
215
                foreach ($this->children as $child) {
1✔
216
                        if (is_object($child)) {
1✔
217
                                return null;
1✔
218
                        }
219

220
                        $s .= $child;
1✔
221
                }
222

223
                return $s;
1✔
224
        }
225

226

227
        /**
228
         * Adds new element's child.
229
         */
230
        final public function add(self|string $child): static
1✔
231
        {
232
                return $this->insert(null, $child);
1✔
233
        }
234

235

236
        /**
237
         * Creates and adds a new HtmlElement child.
238
         */
239
        final public function create(string $name, array|string|null $attrs = null): static
1✔
240
        {
241
                $this->insert(null, $child = new self($name, $attrs));
1✔
242
                return $child;
1✔
243
        }
244

245

246
        /**
247
         * Inserts child node.
248
         */
249
        public function insert(?int $index, self|string $child, bool $replace = false): static
1✔
250
        {
251
                if ($index === null) { // append
1✔
252
                        $this->children[] = $child;
1✔
253

254
                } else { // insert or replace
255
                        array_splice($this->children, (int) $index, $replace ? 1 : 0, [$child]);
1✔
256
                }
257

258
                return $this;
1✔
259
        }
260

261

262
        /**
263
         * Inserts (replaces) child node (ArrayAccess implementation).
264
         * @param  int  $index
265
         * @param  HtmlElement  $child
266
         */
267
        final public function offsetSet($index, $child): void
268
        {
269
                $this->insert($index, $child, replace: true);
1✔
270
        }
1✔
271

272

273
        /**
274
         * Returns child node (ArrayAccess implementation).
275
         * @param  int  $index
276
         */
277
        final public function offsetGet($index): mixed
278
        {
279
                return $this->children[$index];
1✔
280
        }
281

282

283
        /**
284
         * Exists child node? (ArrayAccess implementation).
285
         * @param  int  $index
286
         */
287
        final public function offsetExists($index): bool
288
        {
289
                return isset($this->children[$index]);
1✔
290
        }
291

292

293
        /**
294
         * Removes child node (ArrayAccess implementation).
295
         * @param  int  $index
296
         */
297
        public function offsetUnset($index): void
298
        {
299
                if (isset($this->children[$index])) {
×
300
                        array_splice($this->children, (int) $index, 1);
×
301
                }
302
        }
303

304

305
        /**
306
         * Required by the Countable interface.
307
         */
308
        final public function count(): int
309
        {
310
                return count($this->children);
1✔
311
        }
312

313

314
        /**
315
         * Removed all children.
316
         */
317
        public function removeChildren(): void
318
        {
319
                $this->children = [];
1✔
320
        }
1✔
321

322

323
        /**
324
         * Required by the IteratorAggregate interface.
325
         * @return \ArrayIterator<int, HtmlElement|string>
326
         */
327
        final public function getIterator(): \ArrayIterator
328
        {
329
                return new \ArrayIterator($this->children);
×
330
        }
331

332

333
        /**
334
         * Returns all of children.
335
         * @return list<HtmlElement|string>
336
         */
337
        final public function getChildren(): array
338
        {
339
                return $this->children;
1✔
340
        }
341

342

343
        /**
344
         * Renders element's start tag, content and end tag to internal string representation.
345
         */
346
        final public function toString(Texy $texy): string
1✔
347
        {
348
                $ct = $this->getContentType();
1✔
349
                $s = $texy->protect($this->startTag(), $ct);
1✔
350

351
                // empty elements are finished now
352
                if ($this->isEmpty) {
1✔
353
                        return $s;
1✔
354
                }
355

356
                // add content
357
                foreach ($this->children as $child) {
1✔
358
                        if (is_object($child)) {
1✔
359
                                $s .= $child->toString($texy);
1✔
360
                        } else {
361
                                $s .= $child;
1✔
362
                        }
363
                }
364

365
                // add end tag
366
                return $s . $texy->protect($this->endTag(), $ct);
1✔
367
        }
368

369

370
        /**
371
         * Renders to final HTML.
372
         */
373
        final public function toHtml(Texy $texy): string
1✔
374
        {
375
                return $texy->stringToHtml($this->toString($texy));
1✔
376
        }
377

378

379
        /**
380
         * Renders to final text.
381
         */
382
        final public function toText(Texy $texy): string
1✔
383
        {
384
                return $texy->stringToText($this->toString($texy));
1✔
385
        }
386

387

388
        /**
389
         * Returns element's start tag.
390
         */
391
        public function startTag(): string
392
        {
393
                if (!$this->name) {
1✔
394
                        return '';
1✔
395
                }
396

397
                $s = '<' . $this->name;
1✔
398

399
                foreach ($this->attrs as $key => $value) {
1✔
400
                        if ($value === null || $value === false) {
1✔
401
                                continue; // skip nulls and false boolean attributes
1✔
402

403
                        } elseif ($value === true) {
1✔
404
                                $s .= ' ' . $key; // true boolean attribute
×
405
                                continue;
×
406

407
                        } elseif (is_array($value)) {
1✔
408
                                $tmp = null;
1✔
409
                                foreach ($value as $k => $v) {
1✔
410
                                        if ($v == null) { // skip nulls & empty string; composite 'style' vs. 'others'
1✔
411
                                                continue;
1✔
412
                                        } elseif (is_string($k)) {
1✔
413
                                                $tmp[] = $k . ':' . $v;
1✔
414
                                        } else {
415
                                                $tmp[] = $v;
1✔
416
                                        }
417
                                }
418

419
                                if (!$tmp) {
1✔
420
                                        continue;
1✔
421
                                }
422

423
                                $value = implode($key === 'style' ? ';' : ' ', $tmp);
1✔
424

425
                        } else {
426
                                $value = (string) $value;
1✔
427
                        }
428

429
                        // add new attribute
430
                        $value = str_replace(['&', '"', '<', '>', '@'], ['&amp;', '&quot;', '&lt;', '&gt;', '&#64;'], $value);
1✔
431
                        $s .= ' ' . $key . '="' . Helpers::freezeSpaces($value) . '"';
1✔
432
                }
433

434
                return $s . '>';
1✔
435
        }
436

437

438
        /**
439
         * Returns element's end tag.
440
         */
441
        public function endTag(): string
442
        {
443
                if ($this->name && !$this->isEmpty) {
1✔
444
                        return '</' . $this->name . '>';
1✔
445
                }
446

447
                return '';
1✔
448
        }
449

450

451
        /**
452
         * Clones all children too.
453
         */
454
        public function __clone()
455
        {
456
                foreach ($this->children as $key => $value) {
1✔
457
                        if (is_object($value)) {
×
458
                                $this->children[$key] = clone $value;
×
459
                        }
460
                }
461
        }
1✔
462

463

464
        final public function getContentType(): string
465
        {
466
                $inlineType = self::$inlineElements[$this->name ?? ''] ?? null;
1✔
467
                return $inlineType === null
1✔
468
                        ? Texy::CONTENT_BLOCK
1✔
469
                        : ($inlineType ? Texy::CONTENT_REPLACED : Texy::CONTENT_MARKUP);
1✔
470
        }
471

472

473
        final public function validateAttrs(array $dtd): void
1✔
474
        {
475
                $allowed = $dtd[$this->name][0] ?? null;
1✔
476
                if (is_array($allowed)) {
1✔
477
                        foreach ($this->attrs as $attr => $foo) {
1✔
478
                                if (
479
                                        !isset($allowed[$attr])
1✔
480
                                        && (!isset($allowed['data-*']) || !str_starts_with((string) $attr, 'data-'))
1✔
481
                                        && (!isset($allowed['aria-*']) || !str_starts_with((string) $attr, 'aria-'))
1✔
482
                                ) {
483
                                        unset($this->attrs[$attr]);
1✔
484
                                }
485
                        }
486
                }
487
        }
1✔
488

489

490
        public function validateChild($child, array $dtd): bool
491
        {
492
                if (isset($dtd[$this->name])) {
×
493
                        if ($child instanceof self) {
×
494
                                $child = $child->name;
×
495
                        }
496

497
                        return isset($dtd[$this->name][1][$child]);
×
498
                } else {
499
                        return true; // unknown element
×
500
                }
501
        }
502

503

504
        /**
505
         * Parses text as single line.
506
         */
507
        final public function parseLine(Texy $texy, string $s): LineParser
1✔
508
        {
509
                $parser = new LineParser($texy, $this);
1✔
510
                $parser->parse($s);
1✔
511
                return $parser;
1✔
512
        }
513

514

515
        /**
516
         * Parses text as block.
517
         */
518
        final public function parseBlock(Texy $texy, string $s, bool $indented = false): void
1✔
519
        {
520
                $parser = new BlockParser($texy, $this, $indented);
1✔
521
                $parser->parse($s);
1✔
522
        }
1✔
523
}
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