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

dg / texy / 21344532034

26 Jan 2026 02:43AM UTC coverage: 91.98% (-0.4%) from 92.376%
21344532034

push

github

dg
added CLAUDE.md

2397 of 2606 relevant lines covered (91.98%)

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
 * @implements \ArrayAccess<int, HtmlElement|string>
25
 * @implements \IteratorAggregate<int, HtmlElement|string>
26
 */
27
class HtmlElement implements \ArrayAccess, /* Countable, */ \IteratorAggregate
28
{
29
        public const InnerText = '%TEXT';
30
        public const InnerTransparent = '%TRANS';
31

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

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

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

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

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

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

94

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

108

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

115

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

128

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

137

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

146

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

155

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

164

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

175

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

185

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

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

203

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

214

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

226
                        $s .= $child;
1✔
227
                }
228

229
                return $s;
1✔
230
        }
231

232

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

241

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

252

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

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

265
                return $this;
1✔
266
        }
267

268

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

279

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

289

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

299

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

311

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

320

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

329

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

339

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

349

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

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

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

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

376

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

385

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

394

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

404
                $s = '<' . $this->name;
1✔
405

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

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

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

426
                                if (!$tmp) {
1✔
427
                                        continue;
1✔
428
                                }
429

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

432
                        } else {
433
                                $value = (string) $value;
1✔
434
                        }
435

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

441
                return $s . '>';
1✔
442
        }
443

444

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

454
                return '';
1✔
455
        }
456

457

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

470

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

479

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

497

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

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

512

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

523

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