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('');
+});