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

dg / texy / 22262497275

21 Feb 2026 07:01PM UTC coverage: 93.057% (+0.7%) from 92.367%
22262497275

push

github

dg
added CLAUDE.md

2426 of 2607 relevant lines covered (93.06%)

0.93 hits per line

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

80.62
/src/Texy/HtmlElement.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;
9

10
use function array_splice, count, http_build_query, implode, is_array, is_object, is_string, str_replace, str_starts_with;
11

12

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

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

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

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

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

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

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

92

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

106

107
        /** @param  array<string, mixed>|string|null  $attrs */
108
        public static function el(?string $name = null, array|string|null $attrs = null): self
109
        {
110
                return new self($name, $attrs);
×
111
        }
112

113

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

126

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

135

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

144

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

153

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

162

163
        /**
164
         * Sets element's attribute.
165
         * @param  string|int|bool|string[]|null  $value
166
         */
167
        final public function setAttribute(string $name, string|int|bool|array|null $value): static
168
        {
169
                $this->attrs[$name] = $value;
×
170
                return $this;
×
171
        }
172

173

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

183

184
        /**
185
         * Special setter for element's attribute.
186
         * @param  array<string, mixed>|null  $query
187
         */
188
        final public function href(string $path, ?array $query = null): static
189
        {
190
                if ($query) {
×
191
                        $query = http_build_query($query, '', '&');
×
192
                        if ($query !== '') {
×
193
                                $path .= '?' . $query;
×
194
                        }
195
                }
196

197
                $this->attrs['href'] = $path;
×
198
                return $this;
×
199
        }
200

201

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

212

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

224
                        $s .= $child;
1✔
225
                }
226

227
                return $s;
1✔
228
        }
229

230

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

239

240
        /**
241
         * Creates and adds a new HtmlElement child.
242
         * @param  array<string, mixed>|string|null  $attrs
243
         */
244
        final public function create(string $name, array|string|null $attrs = null): self
1✔
245
        {
246
                $this->insert(null, $child = new self($name, $attrs));
1✔
247
                return $child;
1✔
248
        }
249

250

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

259
                } else { // insert or replace
260
                        array_splice($this->children, (int) $index, $replace ? 1 : 0, [$child]);
1✔
261
                }
262

263
                return $this;
1✔
264
        }
265

266

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

277

278
        /**
279
         * Returns child node (ArrayAccess implementation).
280
         * @param  int  $index
281
         */
282
        final public function offsetGet($index): self|string
283
        {
284
                return $this->children[$index];
1✔
285
        }
286

287

288
        /**
289
         * Exists child node? (ArrayAccess implementation).
290
         * @param  int  $index
291
         */
292
        final public function offsetExists($index): bool
293
        {
294
                return isset($this->children[$index]);
1✔
295
        }
296

297

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

309

310
        /**
311
         * Required by the Countable interface.
312
         */
313
        final public function count(): int
314
        {
315
                return count($this->children);
1✔
316
        }
317

318

319
        /**
320
         * Removed all children.
321
         */
322
        public function removeChildren(): void
323
        {
324
                $this->children = [];
1✔
325
        }
1✔
326

327

328
        /**
329
         * Required by the IteratorAggregate interface.
330
         * @return \ArrayIterator<int, HtmlElement|string>
331
         */
332
        final public function getIterator(): \ArrayIterator
333
        {
334
                return new \ArrayIterator($this->children);
×
335
        }
336

337

338
        /**
339
         * Returns all of children.
340
         * @return list<HtmlElement|string>
341
         */
342
        final public function getChildren(): array
343
        {
344
                return $this->children;
1✔
345
        }
346

347

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

356
                // empty elements are finished now
357
                if ($this->isEmpty) {
1✔
358
                        return $s;
1✔
359
                }
360

361
                // add content
362
                foreach ($this->children as $child) {
1✔
363
                        if (is_object($child)) {
1✔
364
                                $s .= $child->toString($texy);
1✔
365
                        } else {
366
                                $s .= $child;
1✔
367
                        }
368
                }
369

370
                // add end tag
371
                return $s . $texy->protect($this->endTag(), $ct);
1✔
372
        }
373

374

375
        /**
376
         * Renders to final HTML.
377
         */
378
        final public function toHtml(Texy $texy): string
1✔
379
        {
380
                return $texy->stringToHtml($this->toString($texy));
1✔
381
        }
382

383

384
        /**
385
         * Renders to final text.
386
         */
387
        final public function toText(Texy $texy): string
1✔
388
        {
389
                return $texy->stringToText($this->toString($texy));
1✔
390
        }
391

392

393
        /**
394
         * Returns element's start tag.
395
         */
396
        public function startTag(): string
397
        {
398
                if (!$this->name) {
1✔
399
                        return '';
1✔
400
                }
401

402
                $s = '<' . $this->name;
1✔
403

404
                foreach ($this->attrs as $key => $value) {
1✔
405
                        if ($value === null || $value === false) {
1✔
406
                                continue; // skip nulls and false boolean attributes
1✔
407

408
                        } elseif ($value === true) {
1✔
409
                                $s .= ' ' . $key; // true boolean attribute
×
410
                                continue;
×
411

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

424
                                if (!$tmp) {
1✔
425
                                        continue;
1✔
426
                                }
427

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

430
                        } else {
431
                                $value = (string) $value;
1✔
432
                        }
433

434
                        // add new attribute
435
                        $value = str_replace(['&', '"', '<', '>', '@'], ['&amp;', '&quot;', '&lt;', '&gt;', '&#64;'], $value);
1✔
436
                        $s .= ' ' . $key . '="' . Helpers::freezeSpaces($value) . '"';
1✔
437
                }
438

439
                return $s . '>';
1✔
440
        }
441

442

443
        /**
444
         * Returns element's end tag.
445
         */
446
        public function endTag(): string
447
        {
448
                if ($this->name && !$this->isEmpty) {
1✔
449
                        return '</' . $this->name . '>';
1✔
450
                }
451

452
                return '';
1✔
453
        }
454

455

456
        /**
457
         * Clones all children too.
458
         */
459
        public function __clone()
460
        {
461
                foreach ($this->children as $key => $value) {
1✔
462
                        if (is_object($value)) {
×
463
                                $this->children[$key] = clone $value;
×
464
                        }
465
                }
466
        }
1✔
467

468

469
        final public function getContentType(): string
470
        {
471
                $inlineType = self::$inlineElements[$this->name ?? ''] ?? null;
1✔
472
                return $inlineType === null
1✔
473
                        ? Texy::CONTENT_BLOCK
1✔
474
                        : ($inlineType ? Texy::CONTENT_REPLACED : Texy::CONTENT_MARKUP);
1✔
475
        }
476

477

478
        /** @param  array<string, array{array<string, int>, array<string, int>}>  $dtd */
479
        final public function validateAttrs(array $dtd): void
1✔
480
        {
481
                $allowed = $dtd[$this->name ?? ''][0] ?? null;
1✔
482
                if (is_array($allowed)) {
1✔
483
                        foreach ($this->attrs as $attr => $foo) {
1✔
484
                                if (
485
                                        !isset($allowed[$attr])
1✔
486
                                        && (!isset($allowed['data-*']) || !str_starts_with((string) $attr, 'data-'))
1✔
487
                                        && (!isset($allowed['aria-*']) || !str_starts_with((string) $attr, 'aria-'))
1✔
488
                                ) {
489
                                        unset($this->attrs[$attr]);
1✔
490
                                }
491
                        }
492
                }
493
        }
1✔
494

495

496
        /** @param  array<string, array{array<string, int>, array<string, int>}>  $dtd */
497
        public function validateChild(self|string $child, array $dtd): bool
498
        {
499
                if ($this->name !== null && isset($dtd[$this->name])) {
×
500
                        if ($child instanceof self) {
×
501
                                $child = $child->name;
×
502
                        }
503

504
                        return $child !== null && isset($dtd[$this->name][1][$child]);
×
505
                } else {
506
                        return true; // unknown element
×
507
                }
508
        }
509

510

511
        /**
512
         * Parses text as single line.
513
         */
514
        final public function parseLine(Texy $texy, string $s): LineParser
1✔
515
        {
516
                $parser = new LineParser($texy, $this);
1✔
517
                $parser->parse($s);
1✔
518
                return $parser;
1✔
519
        }
520

521

522
        /**
523
         * Parses text as block.
524
         */
525
        final public function parseBlock(Texy $texy, string $s, bool $indented = false): void
1✔
526
        {
527
                $parser = new BlockParser($texy, $this, $indented);
1✔
528
                $parser->parse($s);
1✔
529
        }
1✔
530
}
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