diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 9a9586a..03d2301 100755 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,21 +1,40 @@ -name: Lint -on: [push, pull_request] +name: Coding standards +on: [push] jobs: - lint: - name: PHP Lint - runs-on: ubuntu-latest - steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.3 + lint: + name: Coding standards + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.3', '8.4' ] - - name: Cache Composer dependencies - uses: actions/cache@v4 - with: - path: /tmp/composer-cache - key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + steps: + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/checkout@v4 - - name: lint - run: make app:lint + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + + - name: Setup cache + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v4 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php }}-composer-latest- + + - name: Update composer + run: composer self-update + + - name: Install dependencies with composer + run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Run code quality analysis + run: composer app:lint diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 08d7dc0..d82b540 100755 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,21 +1,40 @@ -name: Test -on: [push, pull_request] +name: Tests +on: [push] jobs: - lint: - name: PHP Test - runs-on: ubuntu-latest - steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.3 + test: + name: Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.3', '8.4' ] - - name: Cache Composer dependencies - uses: actions/cache@v4 - with: - path: /tmp/composer-cache - key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + steps: + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/checkout@v4 - - name: test - run: make app:test + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + + - name: Setup cache + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v4 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php }}-composer-latest- + + - name: Update composer + run: composer self-update + + - name: Install dependencies with composer + run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Run tests + run: composer app:test \ No newline at end of file diff --git a/composer.json b/composer.json index 0c2c1fc..3d9a5bb 100755 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ ], "app:lint:fix": [ "vendor/bin/ecs check --fix", - "venodr/bin/rector" + "vendor/bin/rector" ], "app:test": "vendor/bin/phpunit" } diff --git a/composer.lock b/composer.lock index 970e660..6eeb20b 100755 --- a/composer.lock +++ b/composer.lock @@ -499,16 +499,16 @@ }, { "name": "symfony/html-sanitizer", - "version": "v7.2.2", + "version": "v7.2.3", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "f6bc679b024e30f27e33815930a5b8b304c79813" + "reference": "91443febe34cfa5e8e00425f892e6316db95bc23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f6bc679b024e30f27e33815930a5b8b304c79813", - "reference": "f6bc679b024e30f27e33815930a5b8b304c79813", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/91443febe34cfa5e8e00425f892e6316db95bc23", + "reference": "91443febe34cfa5e8e00425f892e6316db95bc23", "shasum": "" }, "require": { @@ -548,7 +548,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.2.2" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.2.3" }, "funding": [ { @@ -564,7 +564,7 @@ "type": "tidelift" } ], - "time": "2024-12-30T18:35:15+00:00" + "time": "2025-01-27T11:08:17+00:00" }, { "name": "symfony/polyfill-mbstring", diff --git a/src/AbstractBlock.php b/src/AbstractBlock.php new file mode 100755 index 0000000..38d6862 --- /dev/null +++ b/src/AbstractBlock.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Devscast\EditorJs; + +/** + * Class AbstractBlock. + * + * @phpstan-consistent-constructor + * + * @author bernard-ng + */ +abstract readonly class AbstractBlock implements BlockInterface +{ + public const string NAME = 'abstract'; + + protected function __construct( + public string $id, + public string $type, + public array $data, + public ?array $tunes = null, + public ?array $allowedTags = null, + ) { + Assert::notEmpty($id); + } + + public static function create(array $data, array $allowedTags = []): static + { + return new static( + id: $data['id'], + type: $data['type'], + data: $data['data'], + tunes: $data['tunes'] ?? null, + allowedTags: $allowedTags + ); + } + + public function sanitize(): string + { + $sanitizer = Sanitizer::create($this->allowedTags ?? []); + + return $sanitizer->sanitize($this->toHtml()); + } + + #[\Override] + public function getName(): string + { + return static::NAME; + } + + #[\Override] + public function getSchema(): array + { + $filename = sprintf('%s/schemas/%s.schema.json', dirname(__DIR__), $this->getName()); + $content = file_get_contents($filename); + Assert::string($content, sprintf('Schema for block type %s not found', $this->getName())); + + /** @var array $schema */ + $schema = json_decode($content, true); + + return $schema; + } +} diff --git a/src/BlockFactory.php b/src/BlockFactory.php index b4103a8..1b62f3d 100755 --- a/src/BlockFactory.php +++ b/src/BlockFactory.php @@ -14,7 +14,6 @@ namespace Devscast\EditorJs; use Devscast\EditorJs\Blocks\Attaches; -use Devscast\EditorJs\Blocks\Block; use Devscast\EditorJs\Blocks\Code; use Devscast\EditorJs\Blocks\Delimiter; use Devscast\EditorJs\Blocks\Embed; @@ -27,26 +26,45 @@ use Devscast\EditorJs\Blocks\Table; use Devscast\EditorJs\Blocks\Warning; use Devscast\EditorJs\Exception\EditorException; -use Swaggest\JsonSchema\Schema; /** * Class BlockFactory. * * @author bernard-ng */ -abstract readonly class BlockFactory +abstract class BlockFactory { + public const array SUPPORTED_BLOCKS = [ + Attaches::NAME, + Code::NAME, + Delimiter::NAME, + Embed::NAME, + Header::NAME, + Image::NAME, + Listing::NAME, + Paragraph::NAME, + Quote::NAME, + Raw::NAME, + Table::NAME, + Warning::NAME, + ]; + /** * @throws EditorException */ - public static function parse(string $data, ?array $tools = null, array $allowedTags = []): array + public static function parse(string $data, ?array $supportedBlocks = self::SUPPORTED_BLOCKS, array $allowedTags = []): array { try { + /** @var array{time: int, blocks: array} $blocks */ $blocks = json_decode($data, true, flags: JSON_THROW_ON_ERROR); - $mapper = fn (array $block): Block => self::create($block, self::getAllowedTags($block, $allowedTags)); + $mapper = fn (array $block): AbstractBlock => self::create($block, self::getAllowedTags($block, $allowedTags)); + + if ($supportedBlocks !== null) { + $blocks['blocks'] = self::filter($blocks['blocks'], $supportedBlocks); - if ($tools !== null) { - $blocks = self::filter($blocks['blocks'], $tools); + if (empty($blocks['blocks'])) { + throw new EditorException('No valid blocks found after filtering'); + } } return array_map($mapper, $blocks['blocks']); @@ -55,25 +73,14 @@ public static function parse(string $data, ?array $tools = null, array $allowedT } } - private static function filter(array $data, array $tools): array + private static function filter(array $blocks, array $supportedBlocks): array { - return array_filter($data, fn (array $block): bool => isset($tools[$block['type']])); + return array_filter($blocks, fn (array $block) => in_array($block['type'], $supportedBlocks, true)); } - /** - * @throws EditorException - */ - private static function create(array $data, array $allowedTags = []): Block + private static function create(array $data, array $allowedTags = []): AbstractBlock { try { - // TODO: I think this may be very slow for large data need to think of a better way - - //dd(self::getSchema($data['type']), $data); - - $schema = Schema::import(self::getSchema($data['type'])); - - dd($schema); - return match ($data['type']) { 'attaches' => Attaches::create($data, $allowedTags), 'code' => Code::create($data, $allowedTags), @@ -93,19 +100,10 @@ private static function create(array $data, array $allowedTags = []): Block } } - private static function getAllowedTags(mixed $block, array $allowedTags): array + private static function getAllowedTags(array $block, array $allowedTags): array { Assert::keyExists($block, 'type'); return $allowedTags[$block['type']] ?? []; } - - private static function getSchema(string $schema): array - { - $filename = sprintf("%s/schemas/%s.schema.json", dirname(__DIR__), $schema); - $content = file_get_contents($filename); - Assert::string($content, sprintf('Schema for block type %s not found', $schema)); - - return json_decode($content, true); - } } diff --git a/src/BlockInterface.php b/src/BlockInterface.php new file mode 100644 index 0000000..51cbcd0 --- /dev/null +++ b/src/BlockInterface.php @@ -0,0 +1,19 @@ + + */ +interface BlockInterface +{ + public function getName(): string; + + public function toHtml(): string; + + public function getSchema(): array; +} diff --git a/src/Blocks/Attaches.php b/src/Blocks/Attaches.php index 781f5f8..35058af 100755 --- a/src/Blocks/Attaches.php +++ b/src/Blocks/Attaches.php @@ -13,23 +13,26 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** * Class Attaches. * - * File attachments Block for the Editor.js + * File attachments AbstractBlock for the Editor.js * * @see https://github.com/editor-js/attaches * @see https://raw.githubusercontent.com/devscast/editorjs-sanitizer/refs/heads/main/schemas/warning.schema.json * * @author bernard-ng */ -final readonly class Attaches extends Block +final readonly class Attaches extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'attaches'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'attaches'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'file'); Assert::keyExists($data['file'], 'url'); Assert::keyExists($data['file'], 'size'); @@ -37,6 +40,18 @@ public function __construct(string $id, string $type, array $data, ?array $tunes Assert::keyExists($data['file'], 'extension'); Assert::keyExists($data, 'title'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << + + {$this->data['file']['name']} ({$this->data['file']['size']} bytes) + + + HTML; } } diff --git a/src/Blocks/Block.php b/src/Blocks/Block.php deleted file mode 100755 index 3995f36..0000000 --- a/src/Blocks/Block.php +++ /dev/null @@ -1,61 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Devscast\EditorJs\Blocks; - -use Devscast\EditorJs\Assert; -use Devscast\EditorJs\Sanitizer; - -/** - * Class Block. - * - * @author bernard-ng - */ -abstract readonly class Block -{ - public function __construct( - public string $id, - public string $type, - public array $data, - public ?array $tunes = null, - public ?array $allowedTags = null, - ) { - Assert::notEmpty($id); - } - - public static function create(array $data, array $allowedTags = []): static - { - return new static( - id: $data['id'], - type: $data['type'], - data: $data['data'], - tunes: $data['tunes'] ?? null, - allowedTags: $allowedTags - ); - } - -// private function sanitize(array $data, array|string $allowedTags = []): array -// { -// $sanitizer = Sanitizer::create($allowedTags); -// -// foreach ($data as $key => $value) { -// if (is_array($value)) { -// $data[$key] = $this->sanitize($value, $allowedTags); -// } else { -// $data[$key] = $sanitizer->sanitize($value); -// } -// } -// -// return $data; -// } -} diff --git a/src/Blocks/Code.php b/src/Blocks/Code.php index d85dc5c..cdf9016 100755 --- a/src/Blocks/Code.php +++ b/src/Blocks/Code.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,13 +26,25 @@ * * @author bernard-ng */ -final readonly class Code extends Block +final readonly class Code extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'code'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'code'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'code'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << + {$this->data['code']} + + HTML; } } diff --git a/src/Blocks/Delimiter.php b/src/Blocks/Delimiter.php index 2fe68f5..237f941 100755 --- a/src/Blocks/Delimiter.php +++ b/src/Blocks/Delimiter.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,12 +26,22 @@ * * @author bernard-ng */ -final readonly class Delimiter extends Block +final readonly class Delimiter extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'delimiter'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'delimiter'); + Assert::eq($type, self::NAME); + + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } - parent::__construct($id, $type, $data, $tunes, $rules); + #[\Override] + public function toHtml(): string + { + return << + HTML; } } diff --git a/src/Blocks/Embed.php b/src/Blocks/Embed.php index d0c5e63..7cc13e4 100755 --- a/src/Blocks/Embed.php +++ b/src/Blocks/Embed.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,11 +26,13 @@ * * @author bernard-ng */ -final readonly class Embed extends Block +final readonly class Embed extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'embed'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'embed'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'service'); Assert::keyExists($data, 'source'); Assert::keyExists($data, 'embed'); @@ -37,6 +40,17 @@ public function __construct(string $id, string $type, array $data, ?array $tunes Assert::keyExists($data, 'height'); Assert::keyExists($data, 'caption'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << + +
{$this->data['caption']}
+ + HTML; } } diff --git a/src/Blocks/Header.php b/src/Blocks/Header.php index dd49115..6184255 100755 --- a/src/Blocks/Header.php +++ b/src/Blocks/Header.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,15 +26,27 @@ * * @author bernard-ng */ -final readonly class Header extends Block +final readonly class Header extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'header'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'header'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'text'); Assert::keyExists($data, 'level'); Assert::oneOf((int) $data['level'], [1, 2, 3, 4, 5, 6]); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return <<data['level']} id="{$this->id}"> + {$this->data['text']} + data['level']}> + HTML; } } diff --git a/src/Blocks/Image.php b/src/Blocks/Image.php index 737c957..1f8f143 100755 --- a/src/Blocks/Image.php +++ b/src/Blocks/Image.php @@ -13,23 +13,26 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** * Class Image. * - * Image Block for the Editor.js. + * Image AbstractBlock for the Editor.js. * * @see https://github.com/editor-js/image * @see https://raw.githubusercontent.com/devscast/editorjs-sanitizer/refs/heads/main/schemas/image.schema.json * * @author bernard-ng */ -final readonly class Image extends Block +final readonly class Image extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'image'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'image'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'file'); Assert::keyExists($data, 'caption'); Assert::keyExists($data, 'withBorder'); @@ -38,6 +41,17 @@ public function __construct(string $id, string $type, array $data, ?array $tunes Assert::keyExists($data['file'], 'url'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << + {$this->data['caption']} +
{$this->data['caption']}
+ + HTML; } } diff --git a/src/Blocks/Listing.php b/src/Blocks/Listing.php index fb114f8..f8cdf8f 100755 --- a/src/Blocks/Listing.php +++ b/src/Blocks/Listing.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,16 +26,68 @@ * * @author bernard-ng */ -final readonly class Listing extends Block +final readonly class Listing extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'list'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'list'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'items'); Assert::keyExists($data, 'meta'); Assert::keyExists($data, 'style'); Assert::oneOf($data['style'], ['unordered', 'ordered', 'checklist']); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + $tag = $this->getTag($this->data['items']); + $items = $this->renderItems($this->data['items']); + + return trim(<< + {$items} + + HTML); + } + + private function getTag(array $item): string + { + return match ($item['style']) { + 'unordered' => 'ul', + 'ordered' => 'ol', + 'checklist' => 'ul class="checklist"', // inefficient, extract className from tag + default => 'ul' + }; + } + + private function renderItems(array $items): string + { + $renderNestedList = function (array $item): string { + $nestedTag = $this->getTag($item); + $nestedItemsHtml = implode('', array_map(fn ($nestedItem) => sprintf( + '
  • %s
  • ', + is_array($nestedItem) ? ($nestedItem['content'] ?? '') : $nestedItem + ), $item['items'])); + + return << + {$nestedItemsHtml} + + HTML; + }; + + $renderItem = function ($item) use ($renderNestedList): string { + if (is_array($item) && isset($item['type']) && $item['type'] === 'list') { + return sprintf('
  • %s
  • ', $renderNestedList($item)); + } + $content = is_array($item) ? ($item['content'] ?? '') : $item; + return sprintf('
  • %s
  • ', $content); + }; + + return implode('', array_map($renderItem, $items)); } } diff --git a/src/Blocks/Paragraph.php b/src/Blocks/Paragraph.php index 9a3ffd6..f452a3d 100755 --- a/src/Blocks/Paragraph.php +++ b/src/Blocks/Paragraph.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -26,11 +27,13 @@ * * @author bernard-ng */ -final readonly class Paragraph extends Block +final readonly class Paragraph extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'paragraph'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'paragraph'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'text'); if ($tunes !== null) { @@ -38,6 +41,14 @@ public function __construct(string $id, string $type, array $data, ?array $tunes Assert::isArray($tunes['footnotes']); } - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return <<{$this->data['text']}

    + HTML; } } diff --git a/src/Blocks/Quote.php b/src/Blocks/Quote.php index 90502eb..31cc13f 100755 --- a/src/Blocks/Quote.php +++ b/src/Blocks/Quote.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,16 +26,29 @@ * * @author bernard-ng */ -final readonly class Quote extends Block +final readonly class Quote extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'quote'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'quote'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'text'); Assert::keyExists($data, 'caption'); Assert::keyExists($data, 'alignment'); Assert::oneOf($data['alignment'], ['center', 'left']); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << +

    {$this->data['text']}

    +
    {$this->data['caption']}
    + + HTML; } } diff --git a/src/Blocks/Raw.php b/src/Blocks/Raw.php index cce4cef..e3f58d7 100755 --- a/src/Blocks/Raw.php +++ b/src/Blocks/Raw.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,13 +26,25 @@ * * @author bernard-ng */ -final readonly class Raw extends Block +final readonly class Raw extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'raw'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'raw'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'html'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << + {$this->data['html']} + + HTML; } } diff --git a/src/Blocks/Table.php b/src/Blocks/Table.php index e5795cb..6004f6f 100755 --- a/src/Blocks/Table.php +++ b/src/Blocks/Table.php @@ -13,6 +13,7 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** @@ -25,15 +26,56 @@ * * @author bernard-ng */ -final readonly class Table extends Block +final readonly class Table extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'table'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'table'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'withHeadings'); Assert::keyExists($data, 'stretched'); Assert::keyExists($data, 'content'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + $content = $this->data['content']; + $withHeadings = $this->data['withHeadings']; + $stretched = $this->data['stretched'] ? ' class="stretched"' : ''; + + $thead = $withHeadings ? $this->renderHeadings($content[0]) : ''; + $tbody = $this->renderRows(array_slice($content, $withHeadings ? 1 : 0)); + + return << + {$thead} + {$tbody} + + HTML; + } + + private function renderHeadings(array $headers): string + { + $columns = implode('', array_map(fn ($header) => "{$header}", $headers)); + + return << + {$columns} + + HTML; + } + + private function renderRows(array $rows): string + { + $cells = fn ($row) => implode('', array_map(fn ($cell) => sprintf('%s', $cell), $row)); + $html = implode('', array_map(fn ($row) => sprintf('%s', $cells($row)), $rows)); + + return <<{$html} + HTML; } } diff --git a/src/Blocks/Warning.php b/src/Blocks/Warning.php index 46f9b97..c5daf43 100755 --- a/src/Blocks/Warning.php +++ b/src/Blocks/Warning.php @@ -13,13 +13,14 @@ namespace Devscast\EditorJs\Blocks; +use Devscast\EditorJs\AbstractBlock; use Devscast\EditorJs\Assert; /** * Class Warning. * - * Provides Warning Block for the Editor.js. - * Block has title and message. + * Provides Warning AbstractBlock for the Editor.js. + * AbstractBlock has title and message. * It can be used, for example, for editorials notifications or appeals. * * @see https://github.com/editor-js/warning @@ -27,14 +28,27 @@ * * @author bernard-ng */ -final readonly class Warning extends Block +final readonly class Warning extends AbstractBlock { - public function __construct(string $id, string $type, array $data, ?array $tunes, array $rules) + public const string NAME = 'warning'; + + protected function __construct(string $id, string $type, array $data, ?array $tunes = [], ?array $allowedTags = null) { - Assert::eq($type, 'warning'); + Assert::eq($type, self::NAME); Assert::keyExists($data, 'title'); Assert::keyExists($data, 'message'); - parent::__construct($id, $type, $data, $tunes, $rules); + parent::__construct($id, $type, $data, $tunes, $allowedTags); + } + + #[\Override] + public function toHtml(): string + { + return << +

    {$this->data['title']}

    +

    {$this->data['message']}

    + + HTML; } } diff --git a/src/Sanitizer.php b/src/Sanitizer.php index 2891533..8ba31f3 100755 --- a/src/Sanitizer.php +++ b/src/Sanitizer.php @@ -34,12 +34,12 @@ public static function create(string|array $allowedTags = []): HtmlSanitizer if (is_array($allowedTags)) { foreach ($allowedTags as $tag => $attributes) { - $config->allowElement($tag, $attributes); + $config = $config->allowElement($tag, $attributes); } } if (is_string($allowedTags)) { - $config->allowElement($allowedTags); + $config = $config->allowElement($allowedTags); } return new HtmlSanitizer($config); diff --git a/tests/Blocks/AttachesTest.php b/tests/Blocks/AttachesTest.php new file mode 100644 index 0000000..7dafca6 --- /dev/null +++ b/tests/Blocks/AttachesTest.php @@ -0,0 +1,170 @@ + [ + 'url' => 'https://example.com/file.pdf', + 'size' => 1024, + 'name' => 'document.pdf', + 'extension' => 'pdf', + ], + 'title' => 'Important Document', + ]; + + public function testValidConstruction(): void + { + $attaches = new Attaches( + id: 'test123', + type: 'attaches', + data: self::VALID_DATA + ); + + $this->assertInstanceOf(Attaches::class, $attaches); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Attaches( + id: 'test123', + type: 'invalid-type', + data: self::VALID_DATA + ); + } + + public function testMissingFileDataConstruction(): void + { + $this->expectException(EditorException::class); + + $invalidData = self::VALID_DATA; + unset($invalidData['file']); + + new Attaches( + id: 'test123', + type: 'attaches', + data: $invalidData + ); + } + + public function testMissingFilePropertiesConstruction(): void + { + $testCases = [ + 'missing url' => 'url', + 'missing size' => 'size', + 'missing name' => 'name', + 'missing extension' => 'extension', + ]; + + foreach ($testCases as $case => $property) { + $this->expectException(EditorException::class); + // dd($case, $property); + $invalidData = self::VALID_DATA; + unset($invalidData['file'][$property]); + new Attaches( + id: 'test123', + type: 'attaches', + data: $invalidData + ); + } + } + + public function testMissingTitleConstruction(): void + { + $this->expectException(EditorException::class); + $invalidData = self::VALID_DATA; + unset($invalidData['title']); + new Attaches( + id: 'test123', + type: 'attaches', + data: $invalidData + ); + } + + public function testToHtml(): void + { + $attaches = new Attaches( + id: 'test123', + type: 'attaches', + data: self::VALID_DATA + ); + $html = $attaches->toHtml(); + $this->assertStringContainsString('
    ', $html); + $this->assertStringContainsString( + 'href="https://example.com/file.pdf"', + $html + ); + $this->assertStringContainsString( + 'title="Important Document"', + $html + ); + $this->assertStringContainsString( + 'document.pdf (1024 bytes)', + $html + ); + } + + public function testFactoryCreation(): void + { + $data = <<assertCount(1, $blocks); + $this->assertInstanceOf(Attaches::class, $blocks[0]); + } + + public function testFactoryCreationWithInvalidData(): void + { + $this->expectException(EditorException::class); + + $data = << '', + ]; + + public function testValidConstruction(): void + { + $code = new Code( + id: 'XKNT99-qqS741', + type: 'code', + data: self::VALID_DATA + ); + + $this->assertInstanceOf(Code::class, $code); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Code( + id: 'XKNT99-qqS7878', + type: 'invalid-type', + data: self::VALID_DATA + ); + } + + public function testMissingCodeDataConstruction(): void + { + $this->expectException(EditorException::class); + + new Code( + id: 'XKNT99-qqS154', + type: 'code', + data: [] + ); + } + + public function testToHtml(): void + { + $code = new Code( + id: 'XKNT99-qqS321', + type: 'code', + data: self::VALID_DATA + ); + + $html = $code->toHtml(); + + $this->assertStringContainsString('
    ', $html);
    +        $this->assertStringContainsString('', $html);
    +        $this->assertStringContainsString('', $html);
    +        $this->assertStringContainsString('', $html);
    +        $this->assertStringContainsString('
    ', $html); + } + + public function testEmptyCodeContent(): void + { + $code = new Code( + id: 'XKNT99-qqS123', + type: 'code', + data: [ + 'code' => '', + ] + ); + + $html = $code->toHtml(); + $this->assertStringContainsString('', $html); + } +} diff --git a/tests/Blocks/DelimiterTest.php b/tests/Blocks/DelimiterTest.php new file mode 100644 index 0000000..4e1e963 --- /dev/null +++ b/tests/Blocks/DelimiterTest.php @@ -0,0 +1,92 @@ +assertInstanceOf(Delimiter::class, $delimiter); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Delimiter( + id: 'XKNT99-q', + type: 'invalid-type', + data: [] + ); + } + + public function testToHtml(): void + { + $delimiter = new Delimiter( + id: 'XKNT99-q', + type: 'delimiter', + data: [] + ); + $html = $delimiter->toHtml(); + $this->assertSame('
    ', $html); + } + + public function testEmptyDataIsAllowed(): void + { + $delimiter = new Delimiter( + id: 'XKNT99-q', + type: 'delimiter', + data: [] + ); + $html = $delimiter->toHtml(); + $this->assertStringContainsString('
    ', $html); + } + + public function testNonEmptyDataIsIgnored(): void + { + $delimiter = new Delimiter( + id: 'XKNT99-q', + type: 'delimiter', + data: [ + 'some' => 'value', + ] + ); + + $html = $delimiter->toHtml(); + $this->assertStringContainsString('
    ', $html); + } + + public function testIntegrationWithEditor(): void + { + $json = <<getBlocks(); + + $this->assertCount(1, $blocks); + $this->assertInstanceOf(Delimiter::class, $blocks[0]); + $this->assertSame('
    ', $blocks[0]->toHtml()); + } +} diff --git a/tests/Blocks/EmbedTest.php b/tests/Blocks/EmbedTest.php new file mode 100644 index 0000000..dfa9dfa --- /dev/null +++ b/tests/Blocks/EmbedTest.php @@ -0,0 +1,111 @@ + 'youtube', + 'source' => 'https://youtube.com/watch?v=example', + 'embed' => 'https://youtube.com/embed/example', + 'width' => 800, + 'height' => 450, + 'caption' => 'Example video', + ]; + + public function testValidConstruction(): void + { + $embed = new Embed( + id: 'mhTl6ghSkV-emb', + type: 'embed', + data: self::VALID_DATA + ); + + $this->assertInstanceOf(Embed::class, $embed); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Embed( + id: 'mhTl6ghSkV-emb', + type: 'invalid-type', + data: self::VALID_DATA + ); + } + + public function testMissingRequiredFields(): void + { + $requiredFields = ['service', 'source', 'embed', 'width', 'height', 'caption']; + + foreach ($requiredFields as $field) { + $this->expectException(EditorException::class); + + $invalidData = self::VALID_DATA; + unset($invalidData[$field]); + + new Embed( + id: 'mhTl6ghSkV-emb', + type: 'embed', + data: $invalidData + ); + } + } + + public function testToHtml(): void + { + $embed = new Embed( + id: 'mhTl6ghSkV-emb', + type: 'embed', + data: self::VALID_DATA + ); + + $html = trim($embed->toHtml()); + + $expected = << + +
    Example video
    + + HTML; + + $this->assertEquals(trim($expected), $html); + } + + public function testNumericWidthAndHeight(): void + { + $embed = new Embed( + id: 'mhTl6ghSkV-emb', + type: 'embed', + data: array_merge(self::VALID_DATA, [ + 'width' => '800', + 'height' => '450', + ]) + ); + + $html = $embed->toHtml(); + $this->assertStringContainsString('width="800"', $html); + $this->assertStringContainsString('height="450"', $html); + } + + public function testEmptyCaption(): void + { + $embed = new Embed( + id: 'mhTl6ghSkV-emb', + type: 'embed', + data: array_merge(self::VALID_DATA, [ + 'caption' => '', + ]) + ); + + $html = $embed->toHtml(); + $this->assertStringContainsString('
    ', $html); + } +} diff --git a/tests/Blocks/HeaderTest.php b/tests/Blocks/HeaderTest.php new file mode 100644 index 0000000..57f6aa4 --- /dev/null +++ b/tests/Blocks/HeaderTest.php @@ -0,0 +1,128 @@ + 'Sample Header', + 'level' => 2, + ] + ); + + $this->assertInstanceOf(Header::class, $header); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Header( + id: 'test123', + type: 'invalid-type', + data: [ + 'text' => 'Sample Header', + 'level' => 2, + ] + ); + } + + public function testMissingRequiredFields(): void + { + $this->expectException(EditorException::class); + new Header( + id: 'test123', + type: 'header', + data: [ + 'level' => 2, + ] // Missing text + ); + + $this->expectException(EditorException::class); + new Header( + id: 'test123', + type: 'header', + data: [ + 'text' => 'Sample', + ] // Missing level + ); + } + + public function testInvalidHeaderLevel(): void + { + $this->expectException(EditorException::class); + new Header( + id: 'test123', + type: 'header', + data: [ + 'text' => 'Sample Header', + 'level' => 7, // Invalid level + ] + ); + } + + public function testAllValidHeaderLevels(): void + { + foreach (range(1, 6) as $level) { + $header = new Header( + id: 'test-' . $level, + type: 'header', + data: [ + 'text' => "Level {$level} Header", + 'level' => $level, + ] + ); + + $html = trim($header->toHtml()); + $this->assertStringStartsWith("assertStringEndsWith("", $html); + } + } + + public function testToHtmlOutput(): void + { + $header = new Header( + id: 'main-header', + type: 'header', + data: [ + 'text' => 'Welcome to the Site', + 'level' => 1, + ] + ); + + $expected = << + Welcome to the Site + + HTML; + + $this->assertEquals(trim($expected), trim($header->toHtml())); + } + + public function testEmptyHeaderText(): void + { + $header = new Header( + id: 'empty-header', + type: 'header', + data: [ + 'text' => '', + 'level' => 3, + ] + ); + + $html = trim($header->toHtml()); + $this->assertStringContainsString('

    ', $html); + $this->assertStringContainsString('

    ', $html); + } +} diff --git a/tests/Blocks/ImageTest.php b/tests/Blocks/ImageTest.php new file mode 100644 index 0000000..c7c5594 --- /dev/null +++ b/tests/Blocks/ImageTest.php @@ -0,0 +1,110 @@ + [ + 'url' => 'https://example.com/image.jpg', + ], + 'caption' => 'Example image caption', + 'withBorder' => false, + 'withBackground' => false, + 'stretched' => false, + ]; + + public function testValidConstruction(): void + { + $image = new Image( + id: 'img123', + type: 'image', + data: self::VALID_DATA + ); + + $this->assertInstanceOf(Image::class, $image); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + + new Image( + id: 'img123', + type: 'invalid-type', + data: self::VALID_DATA + ); + } + + public function testMissingRequiredFields(): void + { + $requiredFields = ['file', 'caption', 'withBorder', 'withBackground', 'stretched']; + + foreach ($requiredFields as $field) { + $this->expectException(EditorException::class); + + $invalidData = self::VALID_DATA; + unset($invalidData[$field]); + + new Image( + id: 'img123', + type: 'image', + data: $invalidData + ); + } + } + + public function testMissingFileUrl(): void + { + $this->expectException(EditorException::class); + + $invalidData = self::VALID_DATA; + unset($invalidData['file']['url']); + + new Image( + id: 'img123', + type: 'image', + data: $invalidData + ); + } + + public function testToHtmlOutput(): void + { + $image = new Image( + id: 'main-image', + type: 'image', + data: self::VALID_DATA + ); + + $expected = << + Example image caption +
    Example image caption
    + + HTML; + + $this->assertEquals(trim($expected), trim($image->toHtml())); + } + + public function testEmptyCaption(): void + { + $data = self::VALID_DATA; + $data['caption'] = ''; + + $image = new Image( + id: 'empty-caption', + type: 'image', + data: $data + ); + + $html = $image->toHtml(); + $this->assertStringContainsString('
    ', $html); + $this->assertStringContainsString('alt=""', $html); + } +} diff --git a/tests/Blocks/ListingTest.php b/tests/Blocks/ListingTest.php new file mode 100644 index 0000000..c62bdd4 --- /dev/null +++ b/tests/Blocks/ListingTest.php @@ -0,0 +1,213 @@ + ['Item 1', 'Item 2', 'Item 3'], + 'style' => 'unordered', + 'meta' => [], + ]; + + public function testValidConstruction(): void + { + $listing = new Listing( + id: 'list123', + type: 'list', + data: self::VALID_DATA + ); + + $this->assertSame('list123', $listing->id); + $this->assertSame('list', $listing->type); + $this->assertSame(self::VALID_DATA, $listing->data); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + $this->expectExceptionMessage('Expected a value equal to "list". Got: "invalid-type"'); + + new Listing( + id: 'list123', + type: 'invalid-type', + data: self::VALID_DATA + ); + } + + public function testMissingRequiredFields(): void + { + $requiredFields = ['items', 'style', 'meta']; + + foreach ($requiredFields as $field) { + $this->expectException(EditorException::class); + + $invalidData = self::VALID_DATA; + unset($invalidData[$field]); + + new Listing( + id: 'list123', + type: 'list', + data: $invalidData + ); + } + } + + public function testInvalidListStyle(): void + { + $this->expectException(EditorException::class); + $this->expectExceptionMessage('Expected one of: "unordered", "ordered", "checklist". Got: "invalid-style"'); + + $invalidData = self::VALID_DATA; + $invalidData['style'] = 'invalid-style'; + + new Listing( + id: 'list123', + type: 'list', + data: $invalidData + ); + } + + public function testAllValidListStyles(): void + { + $validStyles = [ + 'unordered' => 'ul', + 'ordered' => 'ol', + 'checklist' => 'ul class="checklist"', + ]; + + foreach ($validStyles as $style => $expectedTag) { + $data = self::VALID_DATA; + $data['style'] = $style; + + $listing = new Listing( + id: 'list-' . $style, + type: 'list', + data: $data + ); + + $html = $listing->toHtml(); + + $this->assertStringStartsWith("<{$expectedTag}", $html); + $this->assertStringEndsWith('', $html); + $this->assertStringContainsString('id="list-' . $style . '"', $html); + } + } + + public function testToHtmlOutput(): void + { + $listing = new Listing( + id: 'main-list', + type: 'list', + data: self::VALID_DATA + ); + + $expectedHtml = << +
  • Item 1
  • Item 2
  • Item 3
  • + + HTML; + + $this->assertXmlStringEqualsXmlString($expectedHtml, $listing->toHtml()); + } + + public function testNestedLists(): void + { + $nestedData = [ + 'style' => 'unordered', + 'items' => [ + 'Item 1', + [ + 'type' => 'list', + 'style' => 'ordered', + 'items' => ['Subitem 1', 'Subitem 2'], + 'meta' => [], + ], + 'Item 3', + ], + 'meta' => [], + ]; + + $listing = new Listing( + id: 'nested-list', + type: 'list', + data: $nestedData + ); + + $html = $listing->toHtml(); + + $this->assertStringContainsString('
      assertStringContainsString('
    • Item 1
    • ', $html); + $this->assertStringContainsString('
        ', $html); + $this->assertStringContainsString('
      1. Subitem 1
      2. ', $html); + $this->assertStringContainsString('
      3. Subitem 2
      4. ', $html); + $this->assertStringContainsString('
      5. Item 3
      6. ', $html); + } + + public function testEmptyList(): void + { + $emptyData = [ + 'items' => [], + 'style' => 'ordered', + 'meta' => [], + ]; + + $listing = new Listing( + id: 'empty-list', + type: 'list', + data: $emptyData + ); + + $expected = << + +
      + HTML; + + $this->assertXmlStringEqualsXmlString($expected, $listing->toHtml()); + } + + public function testListItemsRendering(): void + { + $listing = new Listing( + id: 'items-test', + type: 'list', + data: [ + 'items' => ['First', 'Second', 'Third'], + 'style' => 'unordered', + 'meta' => [], + ] + ); + + $html = $listing->toHtml(); + $this->assertStringContainsString('
    • First
    • ', $html); + $this->assertStringContainsString('
    • Second
    • ', $html); + $this->assertStringContainsString('
    • Third
    • ', $html); + } + + public function testChecklistRendering(): void + { + $listing = new Listing( + id: 'checklist-test', + type: 'list', + data: [ + 'items' => ['Task 1', 'Task 2'], + 'style' => 'checklist', + 'meta' => [], + ] + ); + + $html = $listing->toHtml(); + + $this->assertStringStartsWith('
        assertStringContainsString('
      • Task 1
      • ', $html); + $this->assertStringContainsString('
      • Task 2
      • ', $html); + $this->assertStringEndsWith('
      ', $html); + } +} diff --git a/tests/Blocks/ParagraphTest.php b/tests/Blocks/ParagraphTest.php new file mode 100644 index 0000000..f58069e --- /dev/null +++ b/tests/Blocks/ParagraphTest.php @@ -0,0 +1,162 @@ + [ + 'a' => ['href', 'title', 'rel'], + 'strong' => [], + 'em' => [], + 'u' => [], + 'br' => [], + 'p' => ['id'], + ], + ]; + + public function testValidParagraphConstruction(): void + { + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "paragraph", + "data": { + "text": "Hey. Meet the new Editor. On this picture you can see it in action. Then, try a demo 🤓" + } + } + ] + } + JSON; + + $paragraph = BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS)[0]; + + $this->assertInstanceOf(Paragraph::class, $paragraph); + } + + public function testInvalidTypeConstruction(): void + { + $this->expectException(EditorException::class); + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "invalid-type", + "data": { + "text": "This is a test paragraph" + } + } + ] + } + JSON; + + BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS); + } + + public function testMissingTextConstruction(): void + { + $this->expectException(EditorException::class); + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "invalid-type", + "data": {} + } + ] + } + JSON; + + BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS); + } + + public function testToHtml(): void + { + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "paragraph", + "data": { + "text": "This is a test paragraph" + } + } + ] + } + JSON; + + /** @var Paragraph $paragraph */ + $paragraph = BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS)[0]; + + $html = $paragraph->sanitize(); + $this->assertNotEmpty($html, 'HTML should not be empty'); + $this->assertStringContainsString('

      This is a test paragraph

      ', $html); + } + + public function testToHtmlWithAllowedTags(): void + { + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "paragraph", + "data": { + "text": "This is a test paragraph with link" + } + } + ] + } + JSON; + + /** @var Paragraph $paragraph */ + $paragraph = BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS)[0]; + $html = $paragraph->sanitize(); + + $this->assertNotEmpty($html, 'HTML output should not be empty. Output: ' . var_export($html, true)); + $this->assertStringContainsString('test', $html); + $this->assertStringContainsString('link', $html); + } + + public function testToHtmlSanitizesDisallowedTags(): void + { + $data = <<< JSON + { + "time": 1739980616433, + "blocks": [ + { + "id": "mhTl6ghSkV", + "type": "paragraph", + "data": { + "text": "This is a test with " + } + } + ] + } + JSON; + + /** @var Paragraph $paragraph */ + $paragraph = BlockFactory::parse($data, ['paragraph'], self::ALLOWED_TAGS)[0]; + $html = $paragraph->sanitize(); + + $this->assertStringNotContainsString(' quote", + "caption": "Author ", + "alignment": "left" + } + } + ] + } + JSON; + + /** @var Quote $quote */ + $quote = BlockFactory::parse($data, ['quote'], self::ALLOWED_TAGS)[0]; + $html = $quote->sanitize(); + + $this->assertStringNotContainsString('

      This is raw HTML

      " + } + } + ] + } + JSON; + + /** @var Raw $raw */ + $raw = BlockFactory::parse($data, ['raw'], self::ALLOWED_TAGS)[0]; + $html = $raw->sanitize(); + + $this->assertStringNotContainsString('Warning", + "message": "This has " + } + } + ] + } + JSON; + + $allowedTags = [ + 'warning' => [ + 'div' => ['id', 'class'], + 'h2' => [], + 'p' => [], + ], + ]; + + /** @var Warning $warning */ + $warning = BlockFactory::parse($data, ['warning'], $allowedTags)[0]; + $html = $warning->sanitize(); + + $this->assertStringNotContainsString('