WebOrbiton
v1.0.0.1

StocketBase

565 lines · 20.1 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/language.php';
  6. ​
  7. final class BlockEditor
  8. {
  9. private const ALLOWED_TYPES = [
  10. 'paragraph',
  11. 'heading2',
  12. 'heading3',
  13. 'image',
  14. 'video',
  15. 'quote',
  16. 'list',
  17. 'code',
  18. 'table',
  19. 'checklist',
  20. 'callout',
  21. 'separator',
  22. 'embed',
  23. ];
  24. ​
  25. private const SINGLE_USE_TYPES = ['contact_form', 'home_featured', 'home_products'];
  26. ​
  27. public const HOME_SYSTEM_TYPES = ['home_featured', 'home_products'];
  28. ​
  29. private static bool $interactive = true;
  30. ​
  31. public static function sanitize(string $rawJson, bool $allowContactForm = false, bool $homepage = false): string
  32. {
  33. $decoded = json_decode($rawJson, true);
  34. ​
  35. if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
  36. if (!$homepage) {
  37. return json_encode(['blocks' => []]);
  38. }
  39. $decoded = ['blocks' => []];
  40. }
  41. ​
  42. $cleanBlocks = [];
  43. $usedSingleTypes = [];
  44. $allowedTypes = $allowContactForm ? array_merge(self::ALLOWED_TYPES, ['contact_form']) : self::ALLOWED_TYPES;
  45. if ($homepage) {
  46. $allowedTypes = array_merge($allowedTypes, ['hero'], self::HOME_SYSTEM_TYPES);
  47. }
  48. ​
  49. foreach ($decoded['blocks'] as $block) {
  50. if (!is_array($block) || !isset($block['type']) || !in_array($block['type'], $allowedTypes, true)) {
  51. continue;
  52. }
  53. ​
  54. if (in_array($block['type'], self::SINGLE_USE_TYPES, true)) {
  55. if (isset($usedSingleTypes[$block['type']])) {
  56. continue;
  57. }
  58. $usedSingleTypes[$block['type']] = true;
  59. }
  60. ​
  61. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  62. ​
  63. $cleanBlocks[] = [
  64. 'id' => is_string($block['id'] ?? null) ? substr($block['id'], 0, 40) : bin2hex(random_bytes(8)),
  65. 'type' => $block['type'],
  66. 'data' => self::sanitizeBlockData($block['type'], $data),
  67. ];
  68. }
  69. ​
  70. if ($homepage) {
  71. $cleanBlocks = self::withSystemBlocks($cleanBlocks);
  72. }
  73. ​
  74. return json_encode(['blocks' => $cleanBlocks], JSON_UNESCAPED_UNICODE);
  75. }
  76. ​
  77. private static function withSystemBlocks(array $blocks): array
  78. {
  79. $present = [];
  80. foreach ($blocks as $block) {
  81. $present[(string) ($block['type'] ?? '')] = true;
  82. }
  83. ​
  84. foreach (self::HOME_SYSTEM_TYPES as $type) {
  85. if (!isset($present[$type])) {
  86. $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []];
  87. }
  88. }
  89. ​
  90. return $blocks;
  91. }
  92. ​
  93. public static function defaultHomepageBlocks(bool $withHero): string
  94. {
  95. $blocks = [];
  96. if ($withHero) {
  97. $blocks[] = ['id' => 'home_hero', 'type' => 'hero', 'data' => ['eyebrow' => '', 'title' => '', 'subtitle' => '', 'show_eyebrow' => true]];
  98. }
  99. foreach (self::HOME_SYSTEM_TYPES as $type) {
  100. $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []];
  101. }
  102. ​
  103. return json_encode(['blocks' => $blocks], JSON_UNESCAPED_UNICODE);
  104. }
  105. ​
  106. public static function renderHomepage(string $blocksJson, array $settings, array $systemRenderers): string
  107. {
  108. $decoded = json_decode($blocksJson, true);
  109. $blocks = is_array($decoded) && is_array($decoded['blocks'] ?? null) ? $decoded['blocks'] : [];
  110. $blocks = self::withSystemBlocks(array_values(array_filter($blocks, 'is_array')));
  111. ​
  112. self::$interactive = true;
  113. $html = '';
  114. $buffer = '';
  115. $rendered = [];
  116. ​
  117. foreach ($blocks as $block) {
  118. $type = (string) ($block['type'] ?? '');
  119. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  120. ​
  121. if (in_array($type, self::SINGLE_USE_TYPES, true)) {
  122. if (isset($rendered[$type])) {
  123. continue;
  124. }
  125. $rendered[$type] = true;
  126. }
  127. ​
  128. if ($type === 'contact_form') {
  129. continue;
  130. }
  131. ​
  132. if ($type !== 'hero' && !in_array($type, self::HOME_SYSTEM_TYPES, true)) {
  133. $buffer .= self::renderBlock(['type' => $type, 'data' => $data]);
  134. continue;
  135. }
  136. ​
  137. if ($buffer !== '') {
  138. $html .= '<div class="article-content home-content-block">' . $buffer . '</div>';
  139. $buffer = '';
  140. }
  141. ​
  142. if ($type === 'hero') {
  143. $html .= self::renderHero($data, $settings);
  144. continue;
  145. }
  146. ​
  147. $renderer = $systemRenderers[$type] ?? null;
  148. $html .= is_callable($renderer) ? (string) $renderer() : '';
  149. }
  150. ​
  151. if ($buffer !== '') {
  152. $html .= '<div class="article-content home-content-block">' . $buffer . '</div>';
  153. }
  154. ​
  155. return $html;
  156. }
  157. ​
  158. private static function renderHero(array $data, array $settings): string
  159. {
  160. $title = trim((string) ($data['title'] ?? ''));
  161. $title = $title !== '' ? $title : trim((string) ($settings['site_name'] ?? ''));
  162. $subtitle = trim((string) ($data['subtitle'] ?? ''));
  163. $subtitle = $subtitle !== '' ? $subtitle : trim((string) ($settings['site_description'] ?? ''));
  164. $eyebrow = trim((string) ($data['eyebrow'] ?? ''));
  165. $eyebrow = $eyebrow !== '' ? $eyebrow : Language::get('nav_home', 'Home');
  166. $showEyebrow = !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']);
  167. ​
  168. if ($title === '' && $subtitle === '') {
  169. return '';
  170. }
  171. ​
  172. return '<section class="hero-section">'
  173. . ($showEyebrow ? '<div class="hero-eyebrow">' . htmlspecialchars($eyebrow) . '</div>' : '')
  174. . ($title !== '' ? '<h1 class="hero-title">' . htmlspecialchars($title) . '</h1>' : '')
  175. . ($subtitle !== '' ? '<p class="hero-subtitle">' . htmlspecialchars($subtitle) . '</p>' : '')
  176. . '</section>';
  177. }
  178. ​
  179. private static function sanitizeBlockData(string $type, array $data): array
  180. {
  181. return match ($type) {
  182. 'paragraph', 'quote' => [
  183. 'text' => self::cleanHtml((string) ($data['text'] ?? '')),
  184. ],
  185. 'heading2', 'heading3' => [
  186. 'text' => strip_tags((string) ($data['text'] ?? '')),
  187. ],
  188. 'image' => [
  189. 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
  190. 'alt' => strip_tags((string) ($data['alt'] ?? '')),
  191. 'caption' => strip_tags((string) ($data['caption'] ?? '')),
  192. ],
  193. 'video' => [
  194. 'src' => self::cleanUrl((string) ($data['src'] ?? '')),
  195. 'caption' => strip_tags((string) ($data['caption'] ?? '')),
  196. ],
  197. 'list' => [
  198. 'style' => in_array($data['style'] ?? 'unordered', ['ordered', 'unordered'], true) ? $data['style'] : 'unordered',
  199. 'items' => array_map(
  200. static fn($item) => strip_tags((string) $item),
  201. is_array($data['items'] ?? null) ? $data['items'] : []
  202. ),
  203. ],
  204. 'code' => [
  205. 'language' => preg_replace('/[^a-zA-Z0-9_+-]/', '', (string) ($data['language'] ?? 'text')),
  206. 'code' => (string) ($data['code'] ?? ''),
  207. ],
  208. 'table' => [
  209. 'rows' => self::sanitizeTableRows(is_array($data['rows'] ?? null) ? $data['rows'] : []),
  210. ],
  211. 'checklist' => [
  212. 'items' => self::sanitizeChecklistItems(is_array($data['items'] ?? null) ? $data['items'] : []),
  213. ],
  214. 'callout' => [
  215. 'style' => in_array($data['style'] ?? 'info', ['info', 'warning', 'success', 'danger'], true) ? $data['style'] : 'info',
  216. 'text' => strip_tags((string) ($data['text'] ?? '')),
  217. ],
  218. 'embed' => [
  219. 'url' => self::cleanUrl((string) ($data['url'] ?? '')),
  220. ],
  221. 'separator' => [],
  222. 'contact_form' => [
  223. 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 120),
  224. ],
  225. 'hero' => [
  226. 'eyebrow' => mb_substr(trim(strip_tags((string) ($data['eyebrow'] ?? ''))), 0, 80),
  227. 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 160),
  228. 'subtitle' => mb_substr(trim(strip_tags((string) ($data['subtitle'] ?? ''))), 0, 400),
  229. 'show_eyebrow' => !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']),
  230. ],
  231. default => [],
  232. };
  233. }
  234. ​
  235. private static function sanitizeTableRows(array $rows): array
  236. {
  237. $clean = [];
  238. foreach ($rows as $row) {
  239. if (!is_array($row)) {
  240. continue;
  241. }
  242. $clean[] = array_map(static fn($cell) => strip_tags((string) $cell), $row);
  243. }
  244. ​
  245. return $clean;
  246. }
  247. ​
  248. private static function sanitizeChecklistItems(array $items): array
  249. {
  250. $clean = [];
  251. foreach ($items as $item) {
  252. if (!is_array($item)) {
  253. continue;
  254. }
  255. $clean[] = [
  256. 'text' => strip_tags((string) ($item['text'] ?? '')),
  257. 'checked' => (bool) ($item['checked'] ?? false),
  258. ];
  259. }
  260. ​
  261. return $clean;
  262. }
  263. ​
  264. private static function cleanUrl(string $url): string
  265. {
  266. $url = trim($url);
  267. if ($url === '') {
  268. return '';
  269. }
  270. ​
  271. if (str_starts_with($url, 'https://') || str_starts_with($url, 'http://')) {
  272. return filter_var($url, FILTER_SANITIZE_URL) ?: '';
  273. }
  274. ​
  275. if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $url) === 1) {
  276. return '';
  277. }
  278. ​
  279. if (str_starts_with($url, '//')) {
  280. return '';
  281. }
  282. ​
  283. $url = ltrim($url, '/');
  284. ​
  285. return filter_var($url, FILTER_SANITIZE_URL) ?: '';
  286. }
  287. ​
  288. private static function cleanHtml(string $text): string
  289. {
  290. if (trim($text) === '') {
  291. return '';
  292. }
  293. ​
  294. if (!class_exists(\DOMDocument::class)) {
  295. return strip_tags($text, '<b><strong><i><em><a><br><u><s><code>');
  296. }
  297. ​
  298. $allowedTags = ['b', 'strong', 'i', 'em', 'u', 's', 'code', 'a', 'span', 'br'];
  299. $allowedColors = '/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/';
  300. ​
  301. $wrapped = '<?xml encoding="utf-8"?><div>' . $text . '</div>';
  302. ​
  303. $previous = libxml_use_internal_errors(true);
  304. $dom = new \DOMDocument();
  305. $dom->loadHTML($wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NOBLANKS);
  306. libxml_clear_errors();
  307. libxml_use_internal_errors($previous);
  308. ​
  309. $root = $dom->getElementsByTagName('div')->item(0);
  310. if ($root === null) {
  311. return '';
  312. }
  313. ​
  314. self::sanitizeNode($dom, $root, $allowedTags, $allowedColors);
  315. ​
  316. $html = '';
  317. foreach (iterator_to_array($root->childNodes) as $child) {
  318. $html .= $dom->saveHTML($child);
  319. }
  320. ​
  321. return $html;
  322. }
  323. ​
  324. private static function sanitizeNode(\DOMDocument $dom, \DOMNode $node, array $allowedTags, string $allowedColors): void
  325. {
  326. $children = iterator_to_array($node->childNodes);
  327. ​
  328. foreach ($children as $child) {
  329. if ($child instanceof \DOMText) {
  330. continue;
  331. }
  332. ​
  333. if (!$child instanceof \DOMElement) {
  334. $node->removeChild($child);
  335. continue;
  336. }
  337. ​
  338. $tag = strtolower($child->tagName);
  339. ​
  340. if (!in_array($tag, $allowedTags, true)) {
  341. while ($child->firstChild) {
  342. $node->insertBefore($child->firstChild, $child);
  343. }
  344. $node->removeChild($child);
  345. continue;
  346. }
  347. ​
  348. self::sanitizeAttributes($child, $tag, $allowedColors);
  349. self::sanitizeNode($dom, $child, $allowedTags, $allowedColors);
  350. }
  351. }
  352. ​
  353. private static function sanitizeAttributes(\DOMElement $element, string $tag, string $allowedColors): void
  354. {
  355. $keep = [];
  356. ​
  357. if ($tag === 'a') {
  358. $href = self::cleanUrl((string) $element->getAttribute('href'));
  359. if ($href !== '') {
  360. $keep['href'] = $href;
  361. $keep['rel'] = 'noopener noreferrer';
  362. $keep['target'] = '_blank';
  363. }
  364. }
  365. ​
  366. if ($tag === 'span') {
  367. $color = self::extractColor((string) $element->getAttribute('style'), $allowedColors);
  368. if ($color !== '') {
  369. $keep['style'] = 'color:' . $color;
  370. }
  371. }
  372. ​
  373. foreach (iterator_to_array($element->attributes ?? []) as $attribute) {
  374. $element->removeAttribute($attribute->name);
  375. }
  376. ​
  377. foreach ($keep as $name => $value) {
  378. $element->setAttribute($name, $value);
  379. }
  380. }
  381. ​
  382. private static function extractColor(string $style, string $allowedColors): string
  383. {
  384. if (preg_match('/color\s*:\s*(#[0-9a-fA-F]{3,6})/', $style, $matches) !== 1) {
  385. return '';
  386. }
  387. ​
  388. $color = $matches[1];
  389. ​
  390. return preg_match($allowedColors, $color) === 1 ? $color : '';
  391. }
  392. ​
  393. public static function readingTimeMinutes(string $blocksJson): int
  394. {
  395. $decoded = json_decode($blocksJson, true);
  396. if (!is_array($decoded) || !isset($decoded['blocks'])) {
  397. return 1;
  398. }
  399. ​
  400. $wordCount = 0;
  401. foreach ($decoded['blocks'] as $block) {
  402. $wordCount += self::blockWordCount(is_array($block) ? $block : []);
  403. }
  404. ​
  405. return max(1, (int) ceil($wordCount / 200));
  406. }
  407. ​
  408. private static function blockWordCount(array $block): int
  409. {
  410. $type = $block['type'] ?? '';
  411. $data = is_array($block['data'] ?? null) ? $block['data'] : [];
  412. ​
  413. if ($type === 'table') {
  414. $cells = [];
  415. foreach ($data['rows'] ?? [] as $row) {
  416. foreach (is_array($row) ? $row : [] as $cell) {
  417. $cells[] = (string) $cell;
  418. }
  419. }
  420. $text = implode(' ', $cells);
  421. } else {
  422. $text = match ($type) {
  423. 'paragraph', 'quote', 'heading2', 'heading3', 'callout' => strip_tags((string) ($data['text'] ?? '')),
  424. 'list' => implode(' ', array_map('strval', $data['items'] ?? [])),
  425. 'checklist' => implode(' ', array_map(static fn($item) => (string) ($item['text'] ?? ''), $data['items'] ?? [])),
  426. default => '',
  427. };
  428. }
  429. ​
  430. $text = trim($text);
  431. if ($text === '') {
  432. return 0;
  433. }
  434. ​
  435. return count(preg_split('/\s+/', $text));
  436. }
  437. ​
  438. public static function render(string $blocksJson, bool $interactive = true): string
  439. {
  440. $decoded = json_decode($blocksJson, true);
  441. if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
  442. return '';
  443. }
  444. ​
  445. self::$interactive = $interactive;
  446. $html = '';
  447. $renderedSingle = [];
  448. foreach ($decoded['blocks'] as $block) {
  449. if (!is_array($block)) {
  450. continue;
  451. }
  452. $type = (string) ($block['type'] ?? '');
  453. if (in_array($type, self::SINGLE_USE_TYPES, true)) {
  454. if (isset($renderedSingle[$type])) {
  455. continue;
  456. }
  457. $renderedSingle[$type] = true;
  458. }
  459. $html .= self::renderBlock($block);
  460. }
  461. self::$interactive = true;
  462. ​
  463. return $html;
  464. }
  465. ​
  466. public static function containsBlock(string $blocksJson, string $type): bool
  467. {
  468. $decoded = json_decode($blocksJson, true);
  469. if (!is_array($decoded) || !is_array($decoded['blocks'] ?? null)) {
  470. return false;
  471. }
  472. ​
  473. foreach ($decoded['blocks'] as $block) {
  474. if (is_array($block) && ($block['type'] ?? '') === $type) {
  475. return true;
  476. }
  477. }
  478. ​
  479. return false;
  480. }
  481. ​
  482. private static function renderContactForm(array $data): string
  483. {
  484. if (!self::$interactive) {
  485. return '<div class="callout callout-info">' . htmlspecialchars(Language::get('contact_block_placeholder', 'Contact form')) . '</div>';
  486. }
  487. ​
  488. require_once __DIR__ . '/contact-form.php';
  489. ​
  490. return ContactForm::render((string) ($data['title'] ?? ''));
  491. }
  492. ​
  493. private static function renderBlock(array $block): string
  494. {
  495. $type = $block['type'] ?? '';
  496. $data = $block['data'] ?? [];
  497. ​
  498. return match ($type) {
  499. 'paragraph' => '<p>' . ($data['text'] ?? '') . '</p>',
  500. 'quote' => '<blockquote>' . ($data['text'] ?? '') . '</blockquote>',
  501. 'heading2' => '<h2>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h2>',
  502. 'heading3' => '<h3>' . htmlspecialchars((string) ($data['text'] ?? '')) . '</h3>',
  503. 'image' => '<figure><img src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '" alt="' . htmlspecialchars((string) ($data['alt'] ?? '')) . '">' .
  504. (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
  505. 'video' => '<figure><video controls src="' . htmlspecialchars((string) ($data['src'] ?? '')) . '"></video>' .
  506. (($data['caption'] ?? '') !== '' ? '<figcaption>' . htmlspecialchars((string) $data['caption']) . '</figcaption>' : '') . '</figure>',
  507. 'list' => self::renderList($data),
  508. 'code' => self::renderCode($data),
  509. 'table' => self::renderTable($data),
  510. 'checklist' => self::renderChecklist($data),
  511. 'callout' => '<div class="callout callout-' . htmlspecialchars((string) ($data['style'] ?? 'info')) . '">' .
  512. htmlspecialchars((string) ($data['text'] ?? '')) . '</div>',
  513. 'embed' => '<div class="embed-wrapper"><iframe src="' . htmlspecialchars((string) ($data['url'] ?? '')) . '" loading="lazy"></iframe></div>',
  514. 'separator' => '<hr>',
  515. 'contact_form' => self::renderContactForm(is_array($data) ? $data : []),
  516. default => '',
  517. };
  518. }
  519. ​
  520. private static function renderCode(array $data): string
  521. {
  522. $language = preg_replace('/[^a-z0-9+#-]/', '', strtolower((string) ($data['language'] ?? 'text'))) ?: 'text';
  523. $copyLabel = htmlspecialchars(Language::get('code_copy', 'Copy'), ENT_QUOTES);
  524. $copiedLabel = htmlspecialchars(Language::get('code_copied', 'Copied'), ENT_QUOTES);
  525. ​
  526. return '<div class="code-block">' .
  527. '<button type="button" class="code-copy" data-label="' . $copyLabel . '" data-copied="' . $copiedLabel . '">' . $copyLabel . '</button>' .
  528. '<pre><code class="language-' . $language . '">' . htmlspecialchars((string) ($data['code'] ?? '')) . '</code></pre>' .
  529. '</div>';
  530. }
  531. ​
  532. private static function renderList(array $data): string
  533. {
  534. $tag = ($data['style'] ?? 'unordered') === 'ordered' ? 'ol' : 'ul';
  535. $items = '';
  536. foreach ($data['items'] ?? [] as $item) {
  537. $items .= '<li>' . htmlspecialchars((string) $item) . '</li>';
  538. }
  539. return '<' . $tag . '>' . $items . '</' . $tag . '>';
  540. }
  541. ​
  542. private static function renderTable(array $data): string
  543. {
  544. $rows = '';
  545. foreach ($data['rows'] ?? [] as $row) {
  546. $cells = '';
  547. foreach ($row as $cell) {
  548. $cells .= '<td>' . htmlspecialchars((string) $cell) . '</td>';
  549. }
  550. $rows .= '<tr>' . $cells . '</tr>';
  551. }
  552. return '<table><tbody>' . $rows . '</tbody></table>';
  553. }
  554. ​
  555. private static function renderChecklist(array $data): string
  556. {
  557. $items = '';
  558. foreach ($data['items'] ?? [] as $item) {
  559. $checked = !empty($item['checked']) ? 'checked disabled' : 'disabled';
  560. $items .= '<li><input type="checkbox" ' . $checked . '> ' . htmlspecialchars((string) ($item['text'] ?? '')) . '</li>';
  561. }
  562. return '<ul class="checklist">' . $items . '</ul>';
  563. }
  564. }
  565. ​