Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions src/Utils/HTML.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,34 @@ 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;
}

// 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;
Expand All @@ -44,36 +54,37 @@ 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;
}

if (is_bool($value)) {
$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;
}
}
90 changes: 90 additions & 0 deletions tests/DOMSerializer/Marks/LinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,96 @@
expect($result)->toEqual('<a href="https://tiptap.dev">Example Link</a>');
});

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('<a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev">Example Link</a>');
});

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('<a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev">Example Link</a>');
});

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('<a class="tiptap" href="https://tiptap.dev">Example Link</a>');
});

test('link mark can disable target', function () {
$document = [
'type' => 'doc',
Expand Down
90 changes: 90 additions & 0 deletions tests/DOMSerializer/StyleAttributeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Tiptap\Tests\DOMSerializer;

use Tiptap\Core\Node;
use Tiptap\Editor;
use Tiptap\Nodes\Document;
use Tiptap\Nodes\Text;
use Tiptap\Utils\HTML;

// No bundled extension declares a literal 'style' attribute, so the style branch of
// mergeAttributes is only reachable through a custom extension like this one.
class StyleAttributeNode extends Node
{
public static $name = 'styleAttributeNode';

public function addOptions()
{
return [
'HTMLAttributes' => [],
];
}

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('<div style="color: red;">Example</div>');
});

test('a style attribute that is an array drops the attribute', function () {
expect(renderStyleAttributeNode(['a', 'b']))->toEqual('<div>Example</div>');
});

test('a style attribute that is an object drops the attribute', function () {
expect(renderStyleAttributeNode(['k' => 'v']))->toEqual('<div>Example</div>');
});

test('an unrenderable style attribute keeps a style configured on the extension', function () {
expect(renderStyleAttributeNode(['a', 'b'], ['style' => 'color: red']))
->toEqual('<div style="color: red;">Example</div>');
});
Loading
Loading