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']}
+
+ 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}
+ {$tag}>
+ 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}
+ {$nestedTag}>
+ 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']}
+
+
+ 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);
+ }
+}
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
+
+ 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('' . strtok($expectedTag, ' ') . '>', $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 1Item 2Item 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('- Subitem 1
', $html);
+ $this->assertStringContainsString('- Subitem 2
', $html);
+ $this->assertStringContainsString('- Item 3
', $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('