From e7d75d1d8819aac275b5cc408f172c0543993cc0 Mon Sep 17 00:00:00 2001 From: Timo Isik Date: Mon, 31 Aug 2026 08:52:56 +0200 Subject: [PATCH] Hardened rendering and merging of attributes --- src/Utils/HTML.php | 51 ++++--- tests/DOMSerializer/Marks/LinkTest.php | 90 +++++++++++ tests/DOMSerializer/StyleAttributeTest.php | 90 +++++++++++ tests/Utils/HTMLTest.php | 165 +++++++++++++++++++++ 4 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 tests/DOMSerializer/StyleAttributeTest.php diff --git a/src/Utils/HTML.php b/src/Utils/HTML.php index d6f01360b..c2d471190 100644 --- a/src/Utils/HTML.php +++ b/src/Utils/HTML.php @@ -8,16 +8,22 @@ class HTML * Merge an associative array of attributes, * and make sure to merge classes and inline styles. */ - public static function mergeAttributes() + public static function mergeAttributes(...$args): array { - $args = func_get_args(); - - $attributes = array_shift($args); + $attributes = []; foreach ($args as $moreAttributes) { + if (! is_array($moreAttributes) && ! is_object($moreAttributes)) { + continue; + } + foreach ($moreAttributes as $key => $value) { // class="foo bar" if ($key === 'class') { + if (! self::isStringable($value)) { + continue; + } + $attributes['class'] = trim(($attributes['class'] ?? '') . ' ' . $value); continue; @@ -25,7 +31,11 @@ public static function mergeAttributes() // style="color: red;" if ($key === 'style') { - $style = rtrim($attributes['style'] ?? '', '; ') . '; ' . rtrim($value ?? '', ';') . '; '; + if (! self::isStringable($value)) { + continue; + } + + $style = rtrim($attributes['style'] ?? '', '; ') . '; ' . rtrim($value, ';') . '; '; $attributes['style'] = ltrim(trim($style), '; '); continue; @@ -44,24 +54,11 @@ public static function mergeAttributes() */ public static function renderAttributes(array $attrs): string { - // Make boolean values a string, so they can be rendered in HTML - $attrs = array_map(function ($attribute) { - if ($attribute === true) { - return 'true'; - } - - if ($attribute === false) { - return 'false'; - } - - return $attribute; - }, $attrs); - $attributes = []; // class="custom" foreach ($attrs as $name => $value) { - if (! is_scalar($value) || $value === '') { + if (! self::isStringable($value)) { continue; } @@ -69,11 +66,25 @@ public static function renderAttributes(array $attrs): string $value = $value ? 'true' : 'false'; } - $escapedValue = htmlentities((string) $value); + $value = (string) $value; + + if ($value === '') { + continue; + } + + $escapedValue = htmlentities($value); $attributes[] = " {$name}=\"{$escapedValue}\""; } return join($attributes); } + + /** + * Whether a value can be rendered into an attribute. Excludes null. + */ + private static function isStringable($value): bool + { + return is_scalar($value) || $value instanceof \Stringable; + } } diff --git a/tests/DOMSerializer/Marks/LinkTest.php b/tests/DOMSerializer/Marks/LinkTest.php index ba2d371e6..222b6103e 100644 --- a/tests/DOMSerializer/Marks/LinkTest.php +++ b/tests/DOMSerializer/Marks/LinkTest.php @@ -308,6 +308,96 @@ expect($result)->toEqual('Example Link'); }); +test('link mark with an object class drops the attribute', function () { + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Example Link', + 'marks' => [ + [ + 'type' => 'link', + 'attrs' => [ + 'href' => 'https://tiptap.dev', + 'class' => ['k' => 'v'], + ], + ], + ], + ], + ], + ]; + + $result = (new Editor([ + 'extensions' => [ + new StarterKit, + new Link, + ], + ]))->setContent($document)->getHTML(); + + expect($result)->toEqual('Example Link'); +}); + +test('link mark with an array class drops the attribute', function () { + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Example Link', + 'marks' => [ + [ + 'type' => 'link', + 'attrs' => [ + 'href' => 'https://tiptap.dev', + 'class' => ['a', 'b'], + ], + ], + ], + ], + ], + ]; + + $result = (new Editor([ + 'extensions' => [ + new StarterKit, + new Link, + ], + ]))->setContent($document)->getHTML(); + + expect($result)->toEqual('Example Link'); +}); + +test('link mark with an unrenderable class keeps a class configured on the extension', function () { + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Example Link', + 'marks' => [ + [ + 'type' => 'link', + 'attrs' => [ + 'href' => 'https://tiptap.dev', + 'class' => ['a', 'b'], + ], + ], + ], + ], + ], + ]; + + $result = (new Editor([ + 'extensions' => [ + new StarterKit, + new Link(['HTMLAttributes' => ['class' => 'tiptap']]), + ], + ]))->setContent($document)->getHTML(); + + expect($result)->toEqual('Example Link'); +}); + test('link mark can disable target', function () { $document = [ 'type' => 'doc', diff --git a/tests/DOMSerializer/StyleAttributeTest.php b/tests/DOMSerializer/StyleAttributeTest.php new file mode 100644 index 000000000..eab7ff419 --- /dev/null +++ b/tests/DOMSerializer/StyleAttributeTest.php @@ -0,0 +1,90 @@ + [], + ]; + } + + public function addAttributes() + { + return [ + 'style' => [], + ]; + } + + public function parseHTML() + { + return [ + [ + 'tag' => 'div', + ], + ]; + } + + public function renderHTML($node, $HTMLAttributes = []) + { + return ['div', HTML::mergeAttributes($this->options['HTMLAttributes'], $HTMLAttributes), 0]; + } +} + +function renderStyleAttributeNode($style, array $HTMLAttributes = []) +{ + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'styleAttributeNode', + 'attrs' => [ + 'style' => $style, + ], + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Example', + ], + ], + ], + ], + ]; + + return (new Editor([ + 'extensions' => [ + new Document, + new Text, + new StyleAttributeNode(['HTMLAttributes' => $HTMLAttributes]), + ], + ]))->setContent($document)->getHTML(); +} + +test('a style attribute that is a string gets rendered', function () { + expect(renderStyleAttributeNode('color: red'))->toEqual('
Example
'); +}); + +test('a style attribute that is an array drops the attribute', function () { + expect(renderStyleAttributeNode(['a', 'b']))->toEqual('
Example
'); +}); + +test('a style attribute that is an object drops the attribute', function () { + expect(renderStyleAttributeNode(['k' => 'v']))->toEqual('
Example
'); +}); + +test('an unrenderable style attribute keeps a style configured on the extension', function () { + expect(renderStyleAttributeNode(['a', 'b'], ['style' => 'color: red'])) + ->toEqual('
Example
'); +}); diff --git a/tests/Utils/HTMLTest.php b/tests/Utils/HTMLTest.php index d5b479a98..8e54f8050 100644 --- a/tests/Utils/HTMLTest.php +++ b/tests/Utils/HTMLTest.php @@ -2,6 +2,22 @@ use Tiptap\Utils\HTML; +class StringableClass +{ + public function __toString(): string + { + return 'from-toString'; + } +} + +class EmptyStringableClass +{ + public function __toString(): string + { + return ''; + } +} + test('classes are merged properly', function () { $attributes = [ ['class' => 'a'], @@ -58,3 +74,152 @@ expect(HTML::renderAttributes($attributes))->toEqual(' target="_blank" href="https://tiptap.dev"'); }); + +// mergeAttributes concatenates 'class' and 'style' instead of copying them, so a value +// that is not a string has to be dropped before it reaches the concatenation. +test('mergeAttributes drops a class that is an object', function () { + $result = HTML::mergeAttributes(['class' => 'link'], ['class' => json_decode('{"k":"v"}')]); + + expect($result)->toEqual(['class' => 'link']); +}); + +test('mergeAttributes drops a class that is an array', function () { + $result = HTML::mergeAttributes(['class' => 'link'], ['class' => ['a', 'b']]); + + expect($result)->toEqual(['class' => 'link']); +}); + +test('mergeAttributes drops a class that is an object and keeps no class at all', function () { + $result = HTML::mergeAttributes([], ['class' => json_decode('{"k":"v"}')]); + + expect($result)->toEqual([]); +}); + +test('mergeAttributes drops an already merged class that is an array', function () { + $result = HTML::mergeAttributes(['class' => ['a', 'b']], ['class' => 'external']); + + expect($result)->toEqual(['class' => 'external']); +}); + +test('mergeAttributes drops an already merged class that is an object', function () { + $result = HTML::mergeAttributes(['class' => json_decode('{"k":"v"}')], ['class' => 'external']); + + expect($result)->toEqual(['class' => 'external']); +}); + +test('mergeAttributes never renders a class when every value is unrenderable', function () { + $result = HTML::mergeAttributes(['class' => ['a']], ['class' => ['b']]); + + expect(HTML::renderAttributes($result))->toEqual(''); +}); + +test('mergeAttributes drops a style that is an array', function () { + $result = HTML::mergeAttributes(['style' => 'color: red'], ['style' => ['a', 'b']]); + + expect($result)->toEqual(['style' => 'color: red;']); +}); + +test('mergeAttributes drops a style that is an object', function () { + $result = HTML::mergeAttributes(['style' => 'color: red'], ['style' => json_decode('{"k":"v"}')]); + + expect($result)->toEqual(['style' => 'color: red;']); +}); + +test('mergeAttributes drops a style that is an object and keeps no style at all', function () { + $result = HTML::mergeAttributes([], ['style' => json_decode('{"k":"v"}')]); + + expect($result)->toEqual([]); +}); + +test('mergeAttributes drops an already merged style that is an array', function () { + $result = HTML::mergeAttributes(['style' => ['a', 'b']], ['style' => 'font-weight: bold']); + + expect($result)->toEqual(['style' => 'font-weight: bold;']); +}); + +test('mergeAttributes drops a style that is null instead of leaving an empty declaration', function () { + $result = HTML::mergeAttributes(['style' => 'color: red'], ['style' => null]); + + expect($result)->toEqual(['style' => 'color: red;']); +}); + +// mergeAttributes has no type declarations, so callers can hand it anything. +test('mergeAttributes accepts an object as its first argument', function () { + $result = HTML::mergeAttributes(json_decode('{"class":"a"}'), ['class' => 'b']); + + expect($result)->toEqual(['class' => 'a b']); +}); + +test('mergeAttributes ignores an argument that is a string', function () { + $result = HTML::mergeAttributes(['class' => 'a'], 'oops'); + + expect($result)->toEqual(['class' => 'a']); +}); + +test('mergeAttributes ignores an argument that is an integer', function () { + $result = HTML::mergeAttributes(['class' => 'a'], 5); + + expect($result)->toEqual(['class' => 'a']); +}); + +test('mergeAttributes ignores an argument that is null', function () { + $result = HTML::mergeAttributes(['class' => 'a'], null); + + expect($result)->toEqual(['class' => 'a']); +}); + +test('mergeAttributes returns an array when called without arguments', function () { + expect(HTML::mergeAttributes())->toEqual([]); +}); + +// Guards against the fix over-reaching: every other key is copied, not concatenated, +// and null is how callers remove an attribute again. +test('mergeAttributes keeps a null value for keys other than class and style', function () { + $result = HTML::mergeAttributes(['href' => 'https://tiptap.dev'], ['target' => null]); + + expect($result)->toEqual(['href' => 'https://tiptap.dev', 'target' => null]); +}); + +test('mergeAttributes keeps an unrenderable value for keys other than class and style', function () { + $result = HTML::mergeAttributes(['href' => 'https://tiptap.dev'], ['rel' => ['a', 'b']]); + + expect($result)->toEqual(['href' => 'https://tiptap.dev', 'rel' => ['a', 'b']]); +}); + +test('mergeAttributes lets an unrenderable value overwrite an already merged one', function () { + $result = HTML::mergeAttributes(['rel' => 'noopener'], ['rel' => ['a', 'b']]); + + expect($result)->toEqual(['rel' => ['a', 'b']]); +}); + +test('mergeAttributes keeps a class that is null', function () { + $result = HTML::mergeAttributes(['class' => 'link'], ['class' => null]); + + expect($result)->toEqual(['class' => 'link']); +}); + +test('mergeAttributes keeps a class that is a scalar but not a string', function () { + $result = HTML::mergeAttributes(['class' => 'link'], ['class' => 5]); + + expect($result)->toEqual(['class' => 'link 5']); +}); + +test('mergeAttributes keeps a class that can be cast to a string', function () { + $result = HTML::mergeAttributes(['class' => 'link'], ['class' => new StringableClass]); + + expect($result)->toEqual(['class' => 'link from-toString']); +}); + +test('mergeAttributes keeps a value that can be cast to a string', function () { + $result = HTML::mergeAttributes([], ['title' => new StringableClass]); + + expect(HTML::renderAttributes($result))->toEqual(' title="from-toString"'); +}); + +test('renderAttributes keeps values that can be cast to a string', function () { + expect(HTML::renderAttributes(['title' => new StringableClass]))->toEqual(' title="from-toString"'); +}); + +test('renderAttributes drops values that cast to an empty string', function () { + expect(HTML::renderAttributes(['title' => new EmptyStringableClass]))->toEqual(''); +});