v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/language.php';
-
- final class BlockEditor
- {
- private const ALLOWED_TYPES = [
- 'paragraph',
- 'heading2',
- 'heading3',
- 'image',
- 'video',
- 'quote',
- 'list',
- 'code',
- 'table',
- 'checklist',
- 'callout',
- 'separator',
- 'embed',
- ];
-
- private const SINGLE_USE_TYPES = ['contact_form', 'home_featured', 'home_products'];
-
- public const HOME_SYSTEM_TYPES = ['home_featured', 'home_products'];
-
- private static bool $interactive = true;
-
- public static function sanitize(string $rawJson, bool $allowContactForm = false, bool $homepage = false): string
- {
- $decoded = json_decode($rawJson, true);
-
- if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
- if (!$homepage) {
- return json_encode(['blocks' => []]);
- }
- $decoded = ['blocks' => []];
- }
-
- $cleanBlocks = [];
- $usedSingleTypes = [];
- $allowedTypes = $allowContactForm ? array_merge(self::ALLOWED_TYPES, ['contact_form']) : self::ALLOWED_TYPES;
- if ($homepage) {
- $allowedTypes = array_merge($allowedTypes, ['hero'], self::HOME_SYSTEM_TYPES);
- }
-
- foreach ($decoded['blocks'] as $block) {
- if (!is_array($block) || !isset($block['type']) || !in_array($block['type'], $allowedTypes, true)) {
- continue;
- }
-
- if (in_array($block['type'], self::SINGLE_USE_TYPES, true)) {
- if (isset($usedSingleTypes[$block['type']])) {
- continue;
- }
- $usedSingleTypes[$block['type']] = true;
- }
-
- $data = is_array($block['data'] ?? null) ? $block['data'] : [];
-
- $cleanBlocks[] = [
- 'id' => is_string($block['id'] ?? null) ? substr($block['id'], 0, 40) : bin2hex(random_bytes(8)),
- 'type' => $block['type'],
- 'data' => self::sanitizeBlockData($block['type'], $data),
- ];
- }
-
- if ($homepage) {
- $cleanBlocks = self::withSystemBlocks($cleanBlocks);
- }
-
- return json_encode(['blocks' => $cleanBlocks], JSON_UNESCAPED_UNICODE);
- }
-
- private static function withSystemBlocks(array $blocks): array
- {
- $present = [];
- foreach ($blocks as $block) {
- $present[(string) ($block['type'] ?? '')] = true;
- }
-
- foreach (self::HOME_SYSTEM_TYPES as $type) {
- if (!isset($present[$type])) {
- $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []];
- }
- }
-
- return $blocks;
- }
-
- public static function defaultHomepageBlocks(bool $withHero): string
- {
- $blocks = [];
- if ($withHero) {
- $blocks[] = ['id' => 'home_hero', 'type' => 'hero', 'data' => ['eyebrow' => '', 'title' => '', 'subtitle' => '', 'show_eyebrow' => true]];
- }
- foreach (self::HOME_SYSTEM_TYPES as $type) {
- $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []];
- }
-
- return json_encode(['blocks' => $blocks], JSON_UNESCAPED_UNICODE);
- }
-
- public static function renderHomepage(string $blocksJson, array $settings, array $systemRenderers): string
- {
- $decoded = json_decode($blocksJson, true);
- $blocks = is_array($decoded) && is_array($decoded['blocks'] ?? null) ? $decoded['blocks'] : [];
- $blocks = self::withSystemBlocks(array_values(array_filter($blocks, 'is_array')));
-
- self::$interactive = true;
- $html = '';
- $buffer = '';
- $rendered = [];
-
- foreach ($blocks as $block) {
- $type = (string) ($block['type'] ?? '');
- $data = is_array($block['data'] ?? null) ? $block['data'] : [];
-
- if (in_array($type, self::SINGLE_USE_TYPES, true)) {
- if (isset($rendered[$type])) {
- continue;
- }
- $rendered[$type] = true;
- }
-
- if ($type === 'contact_form') {
- continue;
- }
-
- if ($type !== 'hero' && !in_array($type, self::HOME_SYSTEM_TYPES, true)) {
- $buffer .= self::renderBlock(['type' => $type, 'data' => $data]);
- continue;
- }
-
- if ($buffer !== '') {
- $html .= '<div class="article-content home-content-block">' . $buffer . '</div>';
- $buffer = '';
- }
-
- if ($type === 'hero') {
- $html .= self::renderHero($data, $settings);
- continue;
- }
-
- $renderer = $systemRenderers[$type] ?? null;
- $html .= is_callable($renderer) ? (string) $renderer() : '';
- }
-
- if ($buffer !== '') {
- $html .= '<div class="article-content home-content-block">' . $buffer . '</div>';
- }
-
- return $html;
- }
-
- private static function renderHero(array $data, array $settings): string
- {
- $title = trim((string) ($data['title'] ?? ''));
- $title = $title !== '' ? $title : trim((string) ($settings['site_name'] ?? ''));
- $subtitle = trim((string) ($data['subtitle'] ?? ''));
- $subtitle = $subtitle !== '' ? $subtitle : trim((string) ($settings['site_description'] ?? ''));
- $eyebrow = trim((string) ($data['eyebrow'] ?? ''));
- $eyebrow = $eyebrow !== '' ? $eyebrow : Language::get('nav_home', 'Home');
- $showEyebrow = !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']);
-
- if ($title === '' && $subtitle === '') {
- return '';
- }
-
- return '<section class="hero-section">'
- . ($showEyebrow ? '<div class="hero-eyebrow">' . htmlspecialchars($eyebrow) . '</div>' : '')
- . ($title !== '' ? '<h1 class="hero-title">' . htmlspecialchars($title) . '</h1>' : '')
- . ($subtitle !== '' ? '<p class="hero-subtitle">' . htmlspecialchars($subtitle) . '</p>' : '')
- . '</section>';
- }
-
- private static function sanitizeBlockData(string $type, array $data): array
- {
- return match ($type) {
- 'paragraph', 'quote' => [
- 'text' => self::cleanHtml((string) ($data['text'] ?? '')),
- ],
- 'heading2', 'heading3' => [
- 'text' => strip_tags((string) ($data['text'] ?? '')),
- ],
- 'image' => [
- 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
- 'alt' => strip_tags((string) ($data['alt'] ?? '')),
- 'caption' => strip_tags((string) ($data['caption'] ?? '')),
- ],
- 'video' => [
- 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
- 'caption' => strip_tags((string) ($data['caption'] ?? '')),
- ],
- 'list' => [
- 'style' => in_array($data['style'] ?? 'unordered', ['ordered', 'unordered'], true) ? $data['style'] : 'unordered',
- 'items' => array_map(
- static fn($item) => strip_tags((string) $item),
- is_array($data['items'] ?? null) ? $data['items'] : []
- ),
- ],
- 'code' => [
- 'language' => preg_replace('/[^a-zA-Z0-9_+-]/', '', (string) ($data['language'] ?? 'text')),
- 'code' => (string) ($data['code'] ?? ''),
- ],
- 'table' => [
- 'rows' => self::sanitizeTableRows(is_array($data['rows'] ?? null) ? $data['rows'] : []),
- ],
- 'checklist' => [
- 'items' => self::sanitizeChecklistItems(is_array($data['items'] ?? null) ? $data['items'] : []),
- ],
- 'callout' => [
- 'style' => in_array($data['style'] ?? 'info', ['info', 'warning', 'success', 'danger'], true) ? $data['style'] : 'info',
- 'text' => strip_tags((string) ($data['text'] ?? '')),
- ],
- 'embed' => [
- 'url' => self::cleanUrl((string) ($data['url'] ?? '')),
- ],
- 'separator' => [],
- 'contact_form' => [
- 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 120),
- ],
- 'hero' => [
- 'eyebrow' => mb_substr(trim(strip_tags((string) ($data['eyebrow'] ?? ''))), 0, 80),
- 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 160),
- 'subtitle' => mb_substr(trim(strip_tags((string) ($data['subtitle'] ?? ''))), 0, 400),
- 'show_eyebrow' => !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']),
- ],
- default => [],
- };
- }
-
- private static function sanitizeTableRows(array $rows): array
- {
- $clean = [];
- foreach ($rows as $row) {
- if (!is_array($row)) {
- continue;
- }
- $clean[] = array_map(static fn($cell) => strip_tags((string) $cell), $row);
- }
-
- return $clean;
- }
-
- private static function sanitizeChecklistItems(array $items): array
- {
- $clean = [];
- foreach ($items as $item) {
- if (!is_array($item)) {
- continue;
- }
- $clean[] = [
- 'text' => strip_tags((string) ($item['text'] ?? '')),
- 'checked' => (bool) ($item['checked'] ?? false),
- ];
- }
-
- return $clean;
- }
-
- private static function cleanUrl(string $url): string
- {
- $url = trim($url);
- if ($url === '') {
- return '';
- }
-
- if (str_starts_with($url, 'https://') || str_starts_with($url, 'http://')) {
- return filter_var($url, FILTER_SANITIZE_URL) ?: '';
- }
-
- if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $url) === 1) {
- return '';
- }
-
- if (str_starts_with($url, '//')) {
- return '';
- }
-
- $url = ltrim($url, '/');
-
- return filter_var($url, FILTER_SANITIZE_URL) ?: '';
- }
-
- private static function cleanHtml(string $text): string
- {
- if (trim($text) === '') {
- return '';
- }
-
- if (!class_exists(\DOMDocument::class)) {
- return strip_tags($text, '<b><strong><i><em><a><br><u><s><code>');
- }
-
- $allowedTags = ['b', 'strong', 'i', 'em', 'u', 's', 'code', 'a', 'span', 'br'];
- $allowedColors = '/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/';
-
- $wrapped = '<?xml encoding="utf-8"?><div>' . $text . '</div>';
-
- $previous = libxml_use_internal_errors(true);
- $dom = new \DOMDocument();
- $dom->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NOBLANKS);
- libxml_clear_errors();
- libxml_use_internal_errors($previous);
-
- $root = $dom->getElementsByTagName('div')->item(0);
- if ($root === null) {
- return '';
- }
-
- self::sanitizeNode($dom, $root, $allowedTags, $allowedColors);
-
- $html = '';
- foreach (iterator_to_array($root->childNodes) as $child) {
- $html .= $dom->saveHTML($child);
- }
-
- return $html;
- }
-
- private static function sanitizeNode(\DOMDocument $dom, \DOMNode $node, array $allowedTags, string $allowedColors): void
- {
- $children = iterator_to_array($node->childNodes);
-
- foreach ($children as $child) {
- if ($child instanceof \DOMText) {
- continue;
- }
-
- if (!$child instanceof \DOMElement) {
- $node->removeChild($child);
- continue;
- }
-
- $tag = strtolower($child->tagName);
-
- if (!in_array($tag, $allowedTags, true)) {
- while ($child->firstChild) {
- $node->insertBefore($child->firstChild, $child);
- }
- $node->removeChild($child);
- continue;
- }
-
- self::sanitizeAttributes($child, $tag, $allowedColors);
- self::sanitizeNode($dom, $child, $allowedTags, $allowedColors);
- }
- }
-
- private static function sanitizeAttributes(\DOMElement $element, string $tag, string $allowedColors): void
- {
- $keep = [];
-
- if ($tag === 'a') {
- $href = self::cleanUrl((string) $element->getAttribute('href'));
- if ($href !== '') {
- $keep['href'] = $href;
- $keep['rel'] = 'noopener noreferrer';
- $keep['target'] = '_blank';
- }
- }
-
- if ($tag === 'span') {
- $color = self::extractColor((string) $element->getAttribute('style'), $allowedColors);
- if ($color !== '') {
- $keep['style'] = 'color:' . $color;
- }
- }
-
- foreach (iterator_to_array($element->attributes ?? []) as $attribute) {
- $element->removeAttribute($attribute->name);
- }
-
- foreach ($keep as $name => $value) {
- $element->setAttribute($name, $value);
- }
- }
-
- private static function extractColor(string $style, string $allowedColors): string
- {
- if (preg_match('/color\s*:\s*(#[0-9a-fA-F]{3,6})/', $style, $matches) !== 1) {
- return '';
- }
-
- $color = $matches[1];
-
- return preg_match($allowedColors, $color) === 1 ? $color : '';
- }
-
- public static function readingTimeMinutes(string $blocksJson): int
- {
- $decoded = json_decode($blocksJson, true);
- if (!is_array($decoded) || !isset($decoded['blocks'])) {
- return 1;
- }
-
- $wordCount = 0;
- foreach ($decoded['blocks'] as $block) {
- $wordCount += self::blockWordCount(is_array($block) ? $block : []);
- }
-
- return max(1, (int) ceil($wordCount / 200));
- }
-
- private static function blockWordCount(array $block): int
- {
- $type = $block['type'] ?? '';
- $data = is_array($block['data'] ?? null) ? $block['data'] : [];
-
- if ($type === 'table') {
- $cells = [];
- foreach ($data['rows'] ?? [] as $row) {
- foreach (is_array($row) ? $row : [] as $cell) {
- $cells[] = (string) $cell;
- }
- }
- $text = implode(' ', $cells);
- } else {
- $text = match ($type) {
- 'paragraph', 'quote', 'heading2', 'heading3', 'callout' => strip_tags((string) ($data['text'] ?? '')),
- 'list' => implode(' ', array_map('strval', $data['items'] ?? [])),
- 'checklist' => implode(' ', array_map(static fn($item) => (string) ($item['text'] ?? ''), $data['items'] ?? [])),
- default => '',
- };
- }
-
- $text = trim($text);
- if ($text === '') {
- return 0;
- }
-
- return count(preg_split('/\s+/', $text));
- }
-
- public static function render(string $blocksJson, bool $interactive = true): string
- {
- $decoded = json_decode($blocksJson, true);
- if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
- return '';
- }
-
- self::$interactive = $interactive;
- $html = '';
- $renderedSingle = [];
- foreach ($decoded['blocks'] as $block) {
- if (!is_array($block)) {
- continue;
- }
- $type = (string) ($block['type'] ?? '');
- if (in_array($type, self::SINGLE_USE_TYPES, true)) {
- if (isset($renderedSingle[$type])) {
- continue;
- }
- $renderedSingle[$type] = true;
- }
- $html .= self::renderBlock($block);
- }
- self::$interactive = true;
-
- return $html;
- }
-
- public static function containsBlock(string $blocksJson, string $type): bool
- {
- $decoded = json_decode($blocksJson, true);
- if (!is_array($decoded) || !is_array($decoded['blocks'] ?? null)) {
- return false;
- }
-
- foreach ($decoded['blocks'] as $block) {
- if (is_array($block) && ($block['type'] ?? '') === $type) {
- return true;
- }
- }
-
- return false;
- }
-
- private static function renderContactForm(array $data): string
- {
- if (!self::$interactive) {
- return '<div class="callout callout-info">' . htmlspecialchars(Language::get('contact_block_placeholder', 'Contact form')) . '</div>';
- }
-
- require_once __DIR__ . '/contact-form.php';
-
- return ContactForm::render((string) ($data['title'] ?? ''));
- }
-
- private static function renderBlock(array $block): string
- {
- $type = $block['type'] ?? '';
- $data = $block['data'] ?? [];
-
- return match ($type) {
- 'paragraph' => '<p>' . ($data['text'] ?? '') . '</p>',
- 'quote' => '<blockquote>' . ($data['text'] ?? '') . '</blockquote>',
- 'heading2' => '<h2>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h2>',
- 'heading3' => '<h3>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h3>',
- 'image' => '<figure><img src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '" alt="' . htmlspecialchars((string) ($data['alt'] ?? '')) . '">' .
- (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
- 'video' => '<figure><video controls src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '"></video>' .
- (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
- 'list' => self::renderList($data),
- 'code' => self::renderCode($data),
- 'table' => self::renderTable($data),
- 'checklist' => self::renderChecklist($data),
- 'callout' => '<div class="callout callout-' . htmlspecialchars((string) ($data['style'] ?? 'info')) . '">' .
- htmlspecialchars((string) ($data['text'] ?? '')) . '</div>',
- 'embed' => '<div class="embed-wrapper"><iframe src="' . htmlspecialchars((string) ($data['url'] ?? '')) . '" loading="lazy"></iframe></div>',
- 'separator' => '<hr>',
- 'contact_form' => self::renderContactForm(is_array($data) ? $data : []),
- default => '',
- };
- }
-
- private static function renderCode(array $data): string
- {
- $language = preg_replace('/[^a-z0-9+#-]/', '', strtolower((string) ($data['language'] ?? 'text'))) ?: 'text';
- $copyLabel = htmlspecialchars(Language::get('code_copy', 'Copy'), ENT_QUOTES);
- $copiedLabel = htmlspecialchars(Language::get('code_copied', 'Copied'), ENT_QUOTES);
-
- return '<div class="code-block">' .
- '<button type="button" class="code-copy" data-label="' . $copyLabel . '" data-copied="' . $copiedLabel . '">' . $copyLabel . '</button>' .
- '<pre><code class="language-' . $language . '">' . htmlspecialchars((string) ($data['code'] ?? '')) . '</code></pre>' .
- '</div>';
- }
-
- private static function renderList(array $data): string
- {
- $tag = ($data['style'] ?? 'unordered') === 'ordered' ? 'ol' : 'ul';
- $items = '';
- foreach ($data['items'] ?? [] as $item) {
- $items .= '<li>' . htmlspecialchars((string) $item) . '</li>';
- }
- return '<' . $tag . '>' . $items . '</' . $tag . '>';
- }
-
- private static function renderTable(array $data): string
- {
- $rows = '';
- foreach ($data['rows'] ?? [] as $row) {
- $cells = '';
- foreach ($row as $cell) {
- $cells .= '<td>' . htmlspecialchars((string) $cell) . '</td>';
- }
- $rows .= '<tr>' . $cells . '</tr>';
- }
- return '<table><tbody>' . $rows . '</tbody></table>';
- }
-
- private static function renderChecklist(array $data): string
- {
- $items = '';
- foreach ($data['items'] ?? [] as $item) {
- $checked = !empty($item['checked']) ? 'checked disabled' : 'disabled';
- $items .= '<li><input type="checkbox" ' . $checked . '> ' . htmlspecialchars((string) ($item['text'] ?? '')) . '</li>';
- }
- return '<ul class="checklist">' . $items . '</ul>';
- }
- }
-