Skip to content

Commit 751f0d1

Browse files
authored
feat(support): add HtmlString class (#842)
1 parent 467c664 commit 751f0d1

File tree

6 files changed

+182
-1
lines changed

6 files changed

+182
-1
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Support;
6+
7+
use Stringable;
8+
9+
final readonly class HtmlString implements Stringable
10+
{
11+
public function __construct(
12+
private string $value,
13+
) {
14+
}
15+
16+
public function __toString(): string
17+
{
18+
return $this->value;
19+
}
20+
21+
public function toString(): string
22+
{
23+
return $this->value;
24+
}
25+
26+
public function toStringHelper(): StringHelper
27+
{
28+
return new StringHelper($this->value);
29+
}
30+
31+
public static function createTag(string $tag, array $attributes = [], ?string $content = null): self
32+
{
33+
$attributes = self::compileAttributes($attributes);
34+
35+
if ($content || ! self::isSelfClosingTag($tag)) {
36+
return new self(sprintf('<%s%s>%s</%s>', $tag, $attributes, $content ?? '', $tag));
37+
}
38+
39+
return new self(sprintf('<%s%s />', $tag, $attributes));
40+
}
41+
42+
public static function isSelfClosingTag(string $tag): bool
43+
{
44+
return in_array($tag, [
45+
'area',
46+
'base',
47+
'br',
48+
'col',
49+
'embed',
50+
'hr',
51+
'img',
52+
'input',
53+
'link',
54+
'meta',
55+
'param',
56+
'source',
57+
'track',
58+
'wbr',
59+
], strict: true);
60+
}
61+
62+
/**
63+
* Compiles an attribute list to a string of `key="value"`.
64+
* @param array<string,string> $attributes
65+
*/
66+
private static function compileAttributes(array $attributes): string
67+
{
68+
return arr($attributes)
69+
->filter(fn (mixed $value) => ! in_array($value, [false, null], strict: true))
70+
->map(fn (mixed $value, int|string $key) => $value === true ? $key : $key . '="' . $value . '"')
71+
->values()
72+
->implode(' ')
73+
->when(
74+
condition: fn ($string) => $string->length() !== 0,
75+
callback: fn ($string) => $string->prepend(' '),
76+
)
77+
->toString();
78+
}
79+
}

src/Tempest/Support/src/StringHelper.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ public function __construct(Stringable|string|null $string = '')
2525
$this->string = (string) ($string ?? '');
2626
}
2727

28+
/**
29+
* Converts the instance to a {@see \Tempest\Support\HtmlString}.
30+
*/
31+
public function toHtmlString(): HtmlString
32+
{
33+
return new HtmlString($this->string);
34+
}
35+
2836
/**
2937
* Converts the instance to a string.
3038
*/
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Support\Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Stringable;
9+
use Tempest\Support\HtmlString;
10+
use Tempest\Support\StringHelper;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class HtmlStringTest extends TestCase
16+
{
17+
public function test_convertions(): void
18+
{
19+
$this->assertInstanceOf(HtmlString::class, HtmlString::createTag('div'));
20+
$this->assertInstanceOf(Stringable::class, HtmlString::createTag('div'));
21+
$this->assertInstanceOf(StringHelper::class, HtmlString::createTag('div')->toStringHelper());
22+
$this->assertSame('<div></div>', HtmlString::createTag('div')->toStringHelper()->toString());
23+
}
24+
25+
public function test_create_tag(): void
26+
{
27+
$this->assertSame(
28+
expected: '<div></div>',
29+
actual: (string) HtmlString::createTag('div'),
30+
);
31+
32+
$this->assertSame(
33+
expected: '<button type="submit">OK</button>',
34+
actual: (string) HtmlString::createTag('button', ['type' => 'submit'], 'OK'),
35+
);
36+
37+
$this->assertSame(
38+
expected: '<a href="https://example.com">Link</a>',
39+
actual: (string) HtmlString::createTag('a', ['href' => 'https://example.com'], 'Link'),
40+
);
41+
42+
$this->assertSame(
43+
expected: '<script src="https://example.com/script.js"></script>',
44+
actual: (string) HtmlString::createTag('script', ['src' => 'https://example.com/script.js']),
45+
);
46+
47+
$this->assertSame(
48+
expected: '<link href="https://example.com/style.css" rel="stylesheet" />',
49+
actual: (string) HtmlString::createTag('link', ['href' => 'https://example.com/style.css', 'rel' => 'stylesheet']),
50+
);
51+
52+
$this->assertSame(
53+
expected: '<img src="https://example.com/image.jpg" alt="An image" />',
54+
actual: (string) HtmlString::createTag('img', ['src' => 'https://example.com/image.jpg', 'alt' => 'An image']),
55+
);
56+
57+
$this->assertSame(
58+
expected: '<input type="checkbox" checked />',
59+
actual: (string) HtmlString::createTag('input', ['type' => 'checkbox', 'checked' => true]),
60+
);
61+
62+
$this->assertSame(
63+
expected: '<input type="checkbox" />',
64+
actual: (string) HtmlString::createTag('input', ['type' => 'checkbox', 'checked' => false]),
65+
);
66+
}
67+
}

src/Tempest/Support/tests/StringHelperTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use PHPUnit\Framework\Attributes\TestWith;
88
use PHPUnit\Framework\TestCase;
9+
use Tempest\Support\HtmlString;
910
use Tempest\Support\StringHelper;
1011
use function Tempest\Support\arr;
1112
use function Tempest\Support\str;
@@ -601,4 +602,10 @@ public function test_align_left(): void
601602
$this->assertSame(' foo ', str(' foo')->alignLeft(10, padding: 2)->toString());
602603
$this->assertSame(' foo ', str('foo')->alignLeft(2, padding: 2)->toString());
603604
}
605+
606+
public function test_to_html_string(): void
607+
{
608+
$this->assertInstanceOf(HtmlString::class, str('foo')->toHtmlString());
609+
$this->assertSame('foo', (string) str('foo')->toHtmlString());
610+
}
604611
}

src/Tempest/View/src/Renderers/TempestViewRenderer.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tempest\View\Renderers;
66

77
use Stringable;
8+
use Tempest\Support\HtmlString;
89
use Tempest\View\Exceptions\ViewCompilationError;
910
use Tempest\View\GenericView;
1011
use Tempest\View\View;
@@ -96,8 +97,12 @@ private function renderCompiled(View $_view, string $_path): string
9697
return trim(ob_get_clean());
9798
}
9899

99-
public function escape(null|string|Stringable $value): string
100+
public function escape(null|string|HtmlString|Stringable $value): string
100101
{
102+
if ($value instanceof HtmlString) {
103+
return (string) $value;
104+
}
105+
101106
return htmlentities((string) $value);
102107
}
103108
}

tests/Integration/View/TempestViewRendererTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tests\Tempest\Integration\View;
66

7+
use Tempest\Support\HtmlString;
78
use Tempest\View\Exceptions\InvalidElement;
89
use Tempest\View\ViewCache;
910
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -287,6 +288,20 @@ public function test_raw_and_escaped(): void
287288
HTML, $html);
288289
}
289290

291+
public function test_html_string(): void
292+
{
293+
$html = $this->render(view(__DIR__ . '/../../Fixtures/Views/raw-escaped.view.php', var: HtmlString::createTag('h1', content: 'hi')));
294+
295+
$this->assertStringEqualsStringIgnoringLineEndings(
296+
expected: <<<'HTML'
297+
<h1>hi</h1>
298+
&lt;H1&gt;HI&lt;/H1&gt;
299+
<h1>hi</h1>
300+
HTML,
301+
actual: $html,
302+
);
303+
}
304+
290305
public function test_no_double_else_attributes(): void
291306
{
292307
$this->expectException(InvalidElement::class);

0 commit comments

Comments
 (0)