├── LICENSE ├── composer.json └── src ├── Block ├── Block.php ├── DelimiterBlock.php ├── EmbedBlock.php ├── HeaderBlock.php ├── Image │ └── File.php ├── ImageBlock.php ├── ListBlock.php ├── ParagraphBlock.php ├── QuoteBlock.php └── RawBlock.php ├── BlockRenderer ├── BlockRendererInterface.php ├── DelimiterBlockRenderer.php ├── EmbedBlockRenderer.php ├── GenericBlockRenderer.php ├── HeaderBlockRenderer.php ├── ImageBlockRenderer.php ├── ListBlockRenderer.php ├── ParagraphBlockRenderer.php ├── QuoteBlockRenderer.php └── RawBlockRenderer.php ├── Exception ├── InvalidDataException.php ├── InvalidJsonException.php ├── MappingErrorException.php ├── OptionsResolverException.php ├── ParserExceptionInterface.php ├── RendererExceptionInterface.php ├── ReservedKeyException.php ├── UndefinedOptionException.php ├── UnmappedTypeException.php └── UnsupportedBlockException.php ├── Parser ├── Parser.php ├── ParserInterface.php └── ParserResult.php └── Renderer ├── Renderer.php └── RendererInterface.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Setono 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setono/editorjs-php", 3 | "description": "PHP library for handling data from the EditorJS", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Joachim Løvgaard", 9 | "email": "joachim@loevgaard.dk" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "azjezz/psl": "^2.9 || ^3.2", 15 | "cuyz/valinor": "^1.9", 16 | "psr/log": "^1.1 || ^2.0 || ^3.0", 17 | "setono/html-element": "^1.0", 18 | "symfony/options-resolver": "^4.4 || ^5.4 || ^6.0 || ^7.0" 19 | }, 20 | "require-dev": { 21 | "infection/infection": "^0.28.1", 22 | "php-standard-library/psalm-plugin": "^2.3", 23 | "phpunit/phpunit": "^9.6", 24 | "psalm/plugin-phpunit": "^0.19.5", 25 | "setono/code-quality-pack": "^2.6", 26 | "shipmonk/composer-dependency-analyser": "^1.8.2" 27 | }, 28 | "prefer-stable": true, 29 | "autoload": { 30 | "psr-4": { 31 | "Setono\\EditorJS\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Setono\\EditorJS\\": "tests/" 37 | } 38 | }, 39 | "config": { 40 | "allow-plugins": { 41 | "dealerdirect/phpcodesniffer-composer-installer": false, 42 | "ergebnis/composer-normalize": true, 43 | "infection/extension-installer": true 44 | }, 45 | "sort-packages": true 46 | }, 47 | "scripts": { 48 | "analyse": "psalm", 49 | "check-style": "ecs check", 50 | "fix-style": "ecs check --fix", 51 | "phpunit": "phpunit", 52 | "rector": "rector" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Block/Block.php: -------------------------------------------------------------------------------- 1 | width, $this->height); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Block/HeaderBlock.php: -------------------------------------------------------------------------------- 1 | level); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Block/Image/File.php: -------------------------------------------------------------------------------- 1 | caption !== '' 24 | * 25 | * @psalm-assert-if-true non-empty-string $this->caption 26 | */ 27 | public function hasCaption(): bool 28 | { 29 | return '' !== $this->caption; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Block/ListBlock.php: -------------------------------------------------------------------------------- 1 | $items 28 | */ 29 | public readonly array $items, 30 | ) { 31 | parent::__construct($id); 32 | 33 | $this->tag = $style === self::STYLE_ORDERED ? 'ol' : 'ul'; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Block/ParagraphBlock.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 18 | 19 | return (new HtmlElement($this->getOption('tag')))->withClass($this->getClassOption('class')); 20 | } 21 | 22 | /** 23 | * @psalm-assert-if-true DelimiterBlock $block 24 | */ 25 | public function supports(Block $block): bool 26 | { 27 | return $block instanceof DelimiterBlock; 28 | } 29 | 30 | protected function configureOptions(OptionsResolver $optionsResolver): void 31 | { 32 | parent::configureOptions($optionsResolver); 33 | 34 | $optionsResolver->setDefault('tag', 'hr') 35 | ->setAllowedTypes('tag', 'string') 36 | ; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/BlockRenderer/EmbedBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 21 | 22 | return HtmlElement::div( 23 | HtmlElement::iframe() 24 | ->withAttribute('width', $block->width) 25 | ->withAttribute('height', $block->height) 26 | ->withAttribute('src', $block->embed) 27 | ->withAttribute('frameborder', 0) 28 | ->withAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture') 29 | ->withAttribute('allowfullscreen') 30 | ->withClass($this->getClassOption('class')), 31 | )->withClass($this->getClassOption('containerClass')); 32 | } 33 | 34 | protected function configureOptions(OptionsResolver $optionsResolver): void 35 | { 36 | parent::configureOptions($optionsResolver); 37 | 38 | $optionsResolver->setDefault('containerClass', 'container-embed') 39 | ->setAllowedTypes('containerClass', 'string') 40 | ; 41 | } 42 | 43 | /** 44 | * @psalm-assert-if-true EmbedBlock $block 45 | */ 46 | public function supports(Block $block): bool 47 | { 48 | return $block instanceof EmbedBlock; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/BlockRenderer/GenericBlockRenderer.php: -------------------------------------------------------------------------------- 1 | configureOptions($resolver); 20 | 21 | try { 22 | $this->options = $resolver->resolve($options); 23 | } catch (ExceptionInterface $e) { 24 | throw new OptionsResolverException($e, $this); 25 | } 26 | } 27 | 28 | protected function configureOptions(OptionsResolver $optionsResolver): void 29 | { 30 | $optionsResolver->setDefault('class', '') 31 | ->setAllowedTypes('class', 'string') 32 | ->setDefault('classPrefix', 'editorjs-') 33 | ->setAllowedTypes('classPrefix', 'string') 34 | ; 35 | } 36 | 37 | /** 38 | * @psalm-assert-if-true mixed $this->options[$option] 39 | */ 40 | protected function hasOption(string $option): bool 41 | { 42 | return isset($this->options[$option]); 43 | } 44 | 45 | protected function getOption(string $option): mixed 46 | { 47 | if (!$this->hasOption($option)) { 48 | throw new UndefinedOptionException($option, array_keys($this->options)); 49 | } 50 | 51 | return $this->options[$option]; 52 | } 53 | 54 | /** 55 | * This is a helper method to allow you to get an option value which MUST be a (css) class option. 56 | * This method will then prepend the class prefix to the option and return it 57 | */ 58 | protected function getClassOption(string $option): string 59 | { 60 | /** @var mixed $option */ 61 | $option = $this->getOption($option); 62 | if (!is_string($option) || '' === $option) { 63 | return ''; 64 | } 65 | 66 | /** @psalm-suppress MixedArgument */ 67 | return sprintf('%s%s', $this->getOption('classPrefix'), $option); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/BlockRenderer/HeaderBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 20 | 21 | return (new HtmlElement(sprintf('h%d', $block->level), $block->text)) 22 | ->withClass($this->getClassOption('class')) 23 | ; 24 | } 25 | 26 | /** 27 | * @psalm-assert-if-true HeaderBlock $block 28 | */ 29 | public function supports(Block $block): bool 30 | { 31 | return $block instanceof HeaderBlock; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/BlockRenderer/ImageBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 21 | 22 | $container = HtmlElement::div() 23 | ->withClass($this->getClassOption('containerClass')) 24 | ->withClass($this->getClassOption('withBorderClass')) 25 | ->withClass($this->getClassOption('withBackgroundClass')) 26 | ->withClass($this->getClassOption('stretchedClass')) 27 | ; 28 | 29 | $image = HtmlElement::img() 30 | ->withClass($this->getClassOption('imageClass')) 31 | ->withAttribute('src', $block->file->url) 32 | ; 33 | 34 | if ($block->hasCaption()) { 35 | $image = $image->withAttribute('alt', $block->caption); 36 | } 37 | 38 | $container = $container->append( 39 | HtmlElement::div($image)->withClass($this->getClassOption('imageContainerClass')), 40 | ); 41 | 42 | if ($block->hasCaption()) { 43 | $container = $container->append(HtmlElement::div($block->caption) 44 | ->withClass($this->getClassOption('captionContainerClass'))) 45 | ; 46 | } 47 | 48 | return $container; 49 | } 50 | 51 | protected function configureOptions(OptionsResolver $optionsResolver): void 52 | { 53 | parent::configureOptions($optionsResolver); 54 | 55 | $optionsResolver->setDefault('containerClass', 'container-image') 56 | ->setAllowedTypes('containerClass', 'string') 57 | ->setDefault('imageContainerClass', 'image') 58 | ->setAllowedTypes('imageContainerClass', 'string') 59 | ->setDefault('captionContainerClass', 'caption') 60 | ->setAllowedTypes('captionContainerClass', 'string') 61 | ->setDefault('imageClass', '') 62 | ->setAllowedTypes('imageClass', 'string') 63 | ->setDefault('withBorderClass', 'with-border') 64 | ->setAllowedTypes('withBorderClass', 'string') 65 | ->setDefault('withBackgroundClass', 'with-background') 66 | ->setAllowedTypes('withBackgroundClass', 'string') 67 | ->setDefault('stretchedClass', 'stretched') 68 | ->setAllowedTypes('stretchedClass', 'string') 69 | ; 70 | } 71 | 72 | /** 73 | * @psalm-assert-if-true ImageBlock $block 74 | */ 75 | public function supports(Block $block): bool 76 | { 77 | return $block instanceof ImageBlock; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/BlockRenderer/ListBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 21 | 22 | return (new HtmlElement($block->tag, ...array_map( 23 | fn (string $item) => HtmlElement::li($item)->withClass($this->getClassOption('itemClass')), 24 | $block->items, 25 | ))) 26 | ->withClass($this->getClassOption('class')) 27 | ; 28 | } 29 | 30 | protected function configureOptions(OptionsResolver $optionsResolver): void 31 | { 32 | parent::configureOptions($optionsResolver); 33 | 34 | $optionsResolver->setDefault('itemClass', '') 35 | ->setAllowedTypes('itemClass', 'string') 36 | ; 37 | } 38 | 39 | /** 40 | * @psalm-assert-if-true ListBlock $block 41 | */ 42 | public function supports(Block $block): bool 43 | { 44 | return $block instanceof ListBlock; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/BlockRenderer/ParagraphBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 20 | 21 | return HtmlElement::p($block->text)->withClass($this->getClassOption('class')); 22 | } 23 | 24 | /** 25 | * @psalm-assert-if-true ParagraphBlock $block 26 | */ 27 | public function supports(Block $block): bool 28 | { 29 | return $block instanceof ParagraphBlock; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/BlockRenderer/QuoteBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 20 | 21 | $element = HtmlElement::blockquote($block->text) 22 | ->withClass($this->getClassOption('class')) 23 | ; 24 | 25 | if ('' !== $block->caption) { 26 | return $element->append(HtmlElement::cite($block->caption)); 27 | } 28 | 29 | return $element; 30 | } 31 | 32 | /** 33 | * @psalm-assert-if-true QuoteBlock $block 34 | */ 35 | public function supports(Block $block): bool 36 | { 37 | return $block instanceof QuoteBlock; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/BlockRenderer/RawBlockRenderer.php: -------------------------------------------------------------------------------- 1 | supports($block), $block, $this); 20 | 21 | return HtmlElement::div($block->html)->withClass($this->getClassOption('class')); 22 | } 23 | 24 | /** 25 | * @psalm-assert-if-true RawBlock $block 26 | */ 27 | public function supports(Block $block): bool 28 | { 29 | return $block instanceof RawBlock; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exception/InvalidDataException.php: -------------------------------------------------------------------------------- 1 | getMessage(), 16 | self::class . '::$json', 17 | ), 0, $e); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exception/InvalidJsonException.php: -------------------------------------------------------------------------------- 1 | getMessage(), 14 | self::class . '::$json', 15 | ), 0, $e); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/MappingErrorException.php: -------------------------------------------------------------------------------- 1 | $mapping 15 | */ 16 | public function __construct(MappingError $e, string $type, string $mapping) 17 | { 18 | $errorMessage = $e->getMessage() . "\n\n"; 19 | 20 | $messages = Messages::flattenFromNode($e->node())->errors(); 21 | foreach ($messages as $message) { 22 | $errorMessage .= (string) $message . "\n"; 23 | } 24 | 25 | parent::__construct(sprintf( 26 | 'The block type "%s" could not be mapped to the class "%s". The error was: %s', 27 | $type, 28 | $mapping, 29 | $errorMessage, 30 | ), 0, $e); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exception/OptionsResolverException.php: -------------------------------------------------------------------------------- 1 | getMessage(), 18 | ), 0, $e); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/ParserExceptionInterface.php: -------------------------------------------------------------------------------- 1 | $definedOptions 11 | */ 12 | public function __construct(string $option, array $definedOptions) 13 | { 14 | parent::__construct(sprintf( 15 | 'The option "%s" is not defined. Defined options are: [%s]', 16 | $option, 17 | implode(', ', $definedOptions), 18 | )); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/UnmappedTypeException.php: -------------------------------------------------------------------------------- 1 | id, 18 | ); 19 | 20 | if (null !== $blockRenderer) { 21 | $message = sprintf( 22 | 'The block renderer %s does not support the block %s (%s)', 23 | $blockRenderer::class, 24 | $block::class, 25 | $block->id, 26 | ); 27 | } 28 | 29 | parent::__construct($message); 30 | } 31 | 32 | /** 33 | * @psalm-assert true $test 34 | */ 35 | public static function assert(bool $test, Block $block, BlockRendererInterface $blockRenderer): void 36 | { 37 | if (!$test) { 38 | throw new self($block, $blockRenderer); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Parser/Parser.php: -------------------------------------------------------------------------------- 1 | > */ 30 | private array $mapping = [ 31 | 'delimiter' => DelimiterBlock::class, 32 | 'embed' => EmbedBlock::class, 33 | 'header' => HeaderBlock::class, 34 | 'image' => ImageBlock::class, 35 | 'list' => ListBlock::class, 36 | 'paragraph' => ParagraphBlock::class, 37 | 'quote' => QuoteBlock::class, 38 | 'raw' => RawBlock::class, 39 | ]; 40 | 41 | public function parse(string $json): ParserResult 42 | { 43 | try { 44 | $data = json_decode(json: $json, associative: true, flags: \JSON_THROW_ON_ERROR); 45 | } catch (\JsonException $e) { 46 | throw new InvalidJsonException($json, $e); 47 | } 48 | 49 | $specification = Type\shape([ 50 | 'time' => Type\int(), 51 | 'version' => Type\string(), 52 | 'blocks' => Type\vec(Type\shape([ 53 | 'id' => Type\string(), 54 | 'type' => Type\string(), 55 | 'data' => Type\mixed_dict(), 56 | ])), 57 | ]); 58 | 59 | try { 60 | $data = $specification->assert($data); 61 | } catch (Type\Exception\AssertException $e) { 62 | throw new InvalidDataException($json, $e); 63 | } 64 | 65 | /** @var list $blocks */ 66 | $blocks = []; 67 | 68 | foreach ($data['blocks'] as $block) { 69 | $mapping = $this->getMapping($block['type']); 70 | 71 | foreach (array_keys($block['data']) as $key) { 72 | if ('id' === $key) { 73 | throw new ReservedKeyException($key, $block); 74 | } 75 | } 76 | 77 | try { 78 | $blocks[] = $this->getMapperBuilder() 79 | ->mapper() 80 | ->map($mapping, array_merge($block, $block['data'])) 81 | ; 82 | } catch (MappingError $e) { 83 | throw new MappingErrorException($e, $block['type'], $mapping); 84 | } 85 | } 86 | 87 | return new ParserResult( 88 | new \DateTimeImmutable(sprintf('@%d', (int) ($data['time'] / 1000))), // the time is in milliseconds 89 | $data['version'], 90 | $blocks, 91 | ); 92 | } 93 | 94 | public function getMapperBuilder(): MapperBuilder 95 | { 96 | if (null === $this->mapperBuilder) { 97 | $this->mapperBuilder = (new MapperBuilder())->allowSuperfluousKeys(); 98 | } 99 | 100 | return $this->mapperBuilder; 101 | } 102 | 103 | /** 104 | * @psalm-assert-if-true class-string $this->mapping[$type] 105 | */ 106 | public function hasMapping(string $type): bool 107 | { 108 | return isset($this->mapping[$type]); 109 | } 110 | 111 | /** 112 | * @return class-string 113 | * 114 | * @throws UnmappedTypeException if the $type is not mapped 115 | */ 116 | public function getMapping(string $type): string 117 | { 118 | if (!$this->hasMapping($type)) { 119 | throw new UnmappedTypeException($type); 120 | } 121 | 122 | return $this->mapping[$type]; 123 | } 124 | 125 | /** 126 | * @param class-string $class 127 | */ 128 | public function setMapping(string $type, string $class): void 129 | { 130 | $this->mapping[$type] = $class; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Parser/ParserInterface.php: -------------------------------------------------------------------------------- 1 | $blocks */ 15 | public readonly array $blocks, 16 | ) { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Renderer/Renderer.php: -------------------------------------------------------------------------------- 1 | */ 20 | private array $blockRenderers = []; 21 | 22 | private bool $throwOnUnsupported = true; 23 | 24 | public function __construct() 25 | { 26 | $this->logger = new NullLogger(); 27 | } 28 | 29 | public function render(ParserResult $parsingResult): string 30 | { 31 | $html = ''; 32 | 33 | foreach ($parsingResult->blocks as $block) { 34 | try { 35 | $blockRenderer = $this->getBlockRenderer($block); 36 | 37 | $html .= (string) $blockRenderer->render($block); 38 | } catch (\Throwable $e) { 39 | if ($this->throwOnUnsupported) { 40 | throw $e; 41 | } 42 | 43 | $this->logger->error($e->getMessage()); 44 | } 45 | } 46 | 47 | return $html; 48 | } 49 | 50 | private function getBlockRenderer(Block $block): BlockRendererInterface 51 | { 52 | foreach ($this->blockRenderers as $blockRenderer) { 53 | if ($blockRenderer->supports($block)) { 54 | return $blockRenderer; 55 | } 56 | } 57 | 58 | throw new UnsupportedBlockException($block); 59 | } 60 | 61 | /** 62 | * Adds a block renderer to the renderer 63 | */ 64 | public function add(BlockRendererInterface $blockRenderer): void 65 | { 66 | $this->blockRenderers[] = $blockRenderer; 67 | } 68 | 69 | /** 70 | * If true the renderer will throw any exceptions it encounters. 71 | * If false it will not throw the exceptions, but only log them as errors 72 | */ 73 | public function throwOnUnsupported(bool $throwOnUnsupported): void 74 | { 75 | $this->throwOnUnsupported = $throwOnUnsupported; 76 | } 77 | 78 | public function setLogger(LoggerInterface $logger): void 79 | { 80 | $this->logger = $logger; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Renderer/RendererInterface.php: -------------------------------------------------------------------------------- 1 |