Просмотр файла app/Services/BBCode.php

Размер файла: 12.55Kb
  1. <?php
  2.  
  3. declare(strict_types=1);
  4.  
  5. namespace App\Services;
  6.  
  7. use App\Models\Sticker;
  8. use App\Models\User;
  9. use Shieldon\SimpleCache\Cache;
  10.  
  11. /**
  12. * Класс обработки BB-кодов
  13. *
  14. * @license Code and contributions have MIT License
  15. * @link https://visavi.net
  16. * @author Alexander Grigorev <admin@visavi.net>
  17. */
  18. class BBCode
  19. {
  20. /**
  21. * @var array
  22. */
  23. protected static $parsers = [
  24. 'code' => [
  25. 'pattern' => '/\[code\](.+?)\[\/code\]/s',
  26. 'callback' => 'highlightCode'
  27. ],
  28. 'http' => [
  29. 'pattern' => '%\b(((?<=^|\s)\w+://)[^\s()<>\[\]]+)%s',
  30. 'callback' => 'urlReplace',
  31. ],
  32. 'link' => [
  33. 'pattern' => '%\[url\]((\w+://|//|/)[^\s()<>\[\]]+)\[/url\]%s',
  34. 'callback' => 'urlReplace',
  35. ],
  36. 'namedLink' => [
  37. 'pattern' => '%\[url\=((\w+://|//|/)[^\s()<>\[\]]+)\](.+?)\[/url\]%s',
  38. 'callback' => 'urlReplace',
  39. ],
  40. 'image' => [
  41. 'pattern' => '%\[img\]((\w+://|//|/)[^\s()<>\[\]]+\.(jpg|jpeg|png|gif|bmp|webp))\[/img\]%s',
  42. 'replace' => '<div class="media-file"><a href="$1" data-fancybox="gallery"><img src="$1" class="img-fluid" alt="image"></a></div>',
  43. ],
  44. 'bold' => [
  45. 'pattern' => '/\[b\](.+?)\[\/b\]/s',
  46. 'replace' => '<strong>$1</strong>',
  47. ],
  48. 'italic' => [
  49. 'pattern' => '/\[i\](.+?)\[\/i\]/s',
  50. 'replace' => '<em>$1</em>',
  51. ],
  52. 'underLine' => [
  53. 'pattern' => '/\[u\](.+?)\[\/u\]/s',
  54. 'replace' => '<u>$1</u>',
  55. ],
  56. 'lineThrough' => [
  57. 'pattern' => '/\[s\](.+?)\[\/s\]/s',
  58. 'replace' => '<del>$1</del>',
  59. ],
  60. 'fontSize' => [
  61. 'pattern' => '/\[size\=([1-5])\](.+?)\[\/size\]/s',
  62. 'callback' => 'fontSize',
  63. ],
  64. 'fontColor' => [
  65. 'pattern' => '/\[color\=(#[A-f0-9]{6}|#[A-f0-9]{3})\](.+?)\[\/color\]/s',
  66. 'replace' => '<span style="color:$1">$2</span>',
  67. 'iterate' => 5,
  68. ],
  69. 'center' => [
  70. 'pattern' => '/\[center\](.+?)\[\/center\]/s',
  71. 'replace' => '<div style="text-align:center;">$1</div>',
  72. ],
  73. 'quote' => [
  74. 'pattern' => '/\[quote\](.+?)\[\/quote\]/s',
  75. 'replace' => '<blockquote class="blockquote">$1</blockquote>',
  76. 'iterate' => 3,
  77. ],
  78. 'namedQuote' => [
  79. 'pattern' => '/\[quote\=(.+?)\](.+?)\[\/quote\]/s',
  80. 'replace' => '<blockquote class="blockquote">$2<footer class="blockquote-footer">$1</footer></blockquote>',
  81. 'iterate' => 3,
  82. ],
  83. 'orderedList' => [
  84. 'pattern' => '/\[list=1\](.+?)\[\/list\]/s',
  85. 'callback' => 'listReplace',
  86. ],
  87. 'unorderedList' => [
  88. 'pattern' => '/\[list\](.+?)\[\/list\]/s',
  89. 'callback' => 'listReplace',
  90. ],
  91. 'spoiler' => [
  92. 'pattern' => '/\[spoiler\](.+?)\[\/spoiler\]/s',
  93. 'callback' => 'spoilerText',
  94. 'iterate' => 1,
  95. ],
  96. 'shortSpoiler' => [
  97. 'pattern' => '/\[spoiler\=(.+?)\](.+?)\[\/spoiler\]/s',
  98. 'callback' => 'spoilerText',
  99. 'iterate' => 1,
  100. ],
  101. 'hide' => [
  102. 'pattern' => '/\[hide\](.+?)\[\/hide\]/s',
  103. 'callback' => 'hiddenText',
  104. ],
  105. 'youtube' => [
  106. 'pattern' => '/\[youtube\](.*youtu(?:\.be\/|be\.com\/.*(?:vi?\/?=?|embed\/)))([\w-]{11}).*\[\/youtube\]/U',
  107. 'replace' => '<div class="media-file ratio ratio-16x9"><iframe src="//www.youtube.com/embed/$2" allowfullscreen></iframe></div>',
  108. ],
  109. 'username' => [
  110. 'pattern' => '/(?<=^|\s)@([\w\-]{3,20}+)(?=(\s|,))/',
  111. 'callback' => 'userReplace',
  112. ],
  113. ];
  114.  
  115. /**
  116. * Обрабатывает текст
  117. *
  118. * @param string $source текст содержаший BBCode
  119. *
  120. * @return string Распарсенный текст
  121. */
  122. public function parse(string $source): string
  123. {
  124. $source = nl2br($source, false);
  125. $source = str_replace('[cut]', '', $source);
  126.  
  127. foreach (self::$parsers as $parser) {
  128. $iterate = $parser['iterate'] ?? 1;
  129.  
  130. for ($i = 0; $i < $iterate; $i++) {
  131. if (isset($parser['callback'])) {
  132. $source = preg_replace_callback($parser['pattern'], [$this, $parser['callback']], $source);
  133. } else {
  134. $source = preg_replace($parser['pattern'], $parser['replace'], $source);
  135. }
  136. }
  137. }
  138. return $this->clearBreakLines($source);
  139. }
  140.  
  141. /**
  142. * Clear break lines
  143. *
  144. * @param string $source
  145. *
  146. * @return string
  147. */
  148. private function clearBreakLines(string $source): string
  149. {
  150. $tags = [
  151. '</div><br>' => '</div>',
  152. '</pre><br>' => '</pre>',
  153. '</blockquote><br>' => '</blockquote>',
  154. ];
  155.  
  156. return strtr($source, $tags);
  157. }
  158.  
  159. /**
  160. * Очищает текст от BB-кодов
  161. *
  162. * @param string $source Неочищенный текст
  163. *
  164. * @return string Очищенный текст
  165. */
  166. public function clear(string $source): string
  167. {
  168. return preg_replace('/\[(.*?)]/', '', $source);
  169. }
  170.  
  171. /**
  172. * Обрабатывает ссылки
  173. *
  174. * @param array $match ссылка
  175. *
  176. * @return string Обработанная ссылка
  177. */
  178. public function urlReplace(array $match): string
  179. {
  180. $name = $match[3] ?? $match[1];
  181.  
  182. $target = '';
  183. if ($match[2] !== '/') {
  184. /*if (strpos($match[1], siteDomain(config('app.url'))) !== false) {
  185. $match[1] = '//' . str_replace($match[2], '', $match[1]);
  186. } else {*/
  187. $target = ' target="_blank" rel="nofollow"';
  188. //}
  189. }
  190.  
  191. return '<a href="' . $match[1] . '"' . $target . '>' . rawurldecode($name) . '</a>';
  192. }
  193.  
  194. /**
  195. * Обрабатывет списки
  196. *
  197. * @param array $match список
  198. *
  199. * @return string Обработанный список
  200. */
  201. public function listReplace(array $match): string
  202. {
  203. $li = preg_split('/<br>\R/', $match[1], -1, PREG_SPLIT_NO_EMPTY);
  204.  
  205. if (empty($li)) {
  206. return $match[0];
  207. }
  208.  
  209. $list = [];
  210. foreach ($li as $l) {
  211. $list[] = '<li>' . $l . '</li>';
  212. }
  213.  
  214. $tag = ! str_contains($match[0], '[list]') ? 'ol' : 'ul';
  215.  
  216. return '<' . $tag . '>' . implode($list) . '</' . $tag . '>';
  217. }
  218.  
  219. /**
  220. * Обрабатывает размер текста
  221. *
  222. * @param array $match Массив элементов
  223. *
  224. * @return string Обработанный текст
  225. */
  226. public function fontSize(array $match): string
  227. {
  228. $sizes = [1 => 'x-small', 2 => 'small', 3 => 'medium', 4 => 'large', 5 => 'x-large'];
  229.  
  230. return '<span style="font-size:' . $sizes[$match[1]] . '">' . $match[2] . '</span>';
  231. }
  232.  
  233. /**
  234. * Подсвечивает код
  235. *
  236. * @param array $match Массив элементов
  237. *
  238. * @return string Текст с подсветкой
  239. */
  240. public function highlightCode(array $match): string
  241. {
  242. //Чтобы bb-код, стикеры и логины не работали внутри тега [code]
  243. $match[1] = strtr($match[1], [':' => '&#58;', '[' => '&#91;', '@' => '&#64;', '<br>' => '']);
  244.  
  245. return '<pre class="prettyprint">' . $match[1] . '</pre>';
  246. }
  247.  
  248. /**
  249. * Скрывает текст под спойлер
  250. *
  251. * @param array $match массив элементов
  252. *
  253. * @return string код спойлера
  254. */
  255. public function spoilerText(array $match): string
  256. {
  257. $title = empty($match[2]) ? 'Спойлер' : $match[1];
  258. $text = empty($match[2]) ? $match[1] : $match[2];
  259.  
  260. return '<div class="spoiler">
  261. <b class="spoiler-title">' . $title . '</b>
  262. <div class="spoiler-text" style="display: none;">' . $text . '</div>
  263. </div>';
  264. }
  265.  
  266. /**
  267. * Скрывает текст от неавторизованных пользователей
  268. *
  269. * @param array $match массив элементов
  270. *
  271. * @return string скрытый код
  272. */
  273. public function hiddenText(array $match): string
  274. {
  275. return '<div class="hidden-text">
  276. <span class="fw-bold">Скрытый текст:</span> ' .
  277. (getUser() ? $match[1] : 'Авторизуйтесь для просмотра текста') .
  278. '</div>';
  279. }
  280.  
  281. /**
  282. * Обрабатывает логины пользователей
  283. *
  284. * @param array $match
  285. *
  286. * @return string
  287. */
  288. public function userReplace(array $match): string
  289. {
  290. static $users;
  291.  
  292. if (empty($users)) {
  293. $cache = app(Cache::class);
  294. $users = $cache->get('users');
  295. if (! $users) {
  296. $users = User::query()->get()->pluck('name', 'login');
  297. $cache->set('users', $users, 3600);
  298. }
  299. }
  300.  
  301. if (! array_key_exists($match[1], $users)) {
  302. return $match[0];
  303. }
  304.  
  305. $name = $users[$match[1]] ?: $match[1];
  306.  
  307. return '<a href="/users/' . $match[1] . '">' . escape($name) . '</a>';
  308. }
  309.  
  310. /**
  311. * Обрабатывет стикеры
  312. *
  313. * @param string $source
  314. *
  315. * @return string Обработанный текст
  316. */
  317. public function parseStickers(string $source): string
  318. {
  319. static $stickers;
  320.  
  321. if (empty($stickers)) {
  322. $stickers = Sticker::query()->get()->pluck('path', 'code');
  323. array_walk($stickers, static fn (&$a, $b) => $a = '<img src="' . $a . '" alt="' . $b . '">');
  324. }
  325.  
  326. return strtr($source, $stickers);
  327. }
  328.  
  329. /**
  330. * Добавляет или переопределяет парсер
  331. *
  332. * @param string $name Parser name
  333. * @param string $pattern Pattern
  334. * @param string $replace Replace pattern
  335. *
  336. * @return void
  337. */
  338. public function setParser(string $name, string $pattern, string $replace): void
  339. {
  340. self::$parsers[$name] = [
  341. 'pattern' => $pattern,
  342. 'replace' => $replace
  343. ];
  344. }
  345.  
  346. /**
  347. * Устанавливает список доступных парсеров
  348. *
  349. * @param mixed $only parsers
  350. *
  351. * @return BBCode object
  352. */
  353. public function only(mixed $only = null): self
  354. {
  355. $only = is_array($only) ? $only : func_get_args();
  356. self::$parsers = $this->arrayOnly($only);
  357.  
  358. return $this;
  359. }
  360.  
  361. /**
  362. * Исключает парсеры из набора
  363. *
  364. * @param mixed $except parsers
  365. *
  366. * @return BBCode object
  367. */
  368. public function except(mixed $except = null): self
  369. {
  370. $except = is_array($except) ? $except : func_get_args();
  371. self::$parsers = $this->arrayExcept($except);
  372.  
  373. return $this;
  374. }
  375.  
  376. /**
  377. * Возвращает список всех парсеров
  378. *
  379. * @return array array of parsers
  380. */
  381. public function getParsers(): array
  382. {
  383. return self::$parsers;
  384. }
  385.  
  386. /**
  387. * Закрывает bb-теги
  388. *
  389. * @param string $text
  390. *
  391. * @return string
  392. */
  393. public function closeTags(string $text): string
  394. {
  395. preg_match_all('#\[([a-z]+)(?:=.*)?(?<![/])]#iU', $text, $result);
  396. $openTags = $result[1];
  397.  
  398. preg_match_all('#\[/([a-z]+)]#iU', $text, $result);
  399. $closedTags = $result[1];
  400.  
  401. if ($openTags === $closedTags) {
  402. return $text;
  403. }
  404.  
  405. $diff = array_diff_assoc($openTags, $closedTags);
  406. $tags = array_reverse($diff);
  407.  
  408. foreach ($tags as $value) {
  409. $text .= '[/' . $value . ']';
  410. }
  411.  
  412. return $text;
  413. }
  414.  
  415. /**
  416. * Filters all parsers that you don´t want
  417. *
  418. * @param array $only chosen parsers
  419. *
  420. * @return array parsers
  421. */
  422. private function arrayOnly(array $only): array
  423. {
  424. return array_intersect_key(self::$parsers, array_flip($only));
  425. }
  426.  
  427. /**
  428. * Removes the parsers that you don´t want
  429. *
  430. * @param array $excepts
  431. *
  432. * @return array parsers
  433. */
  434. private function arrayExcept(array $excepts): array
  435. {
  436. return array_diff_key(self::$parsers, array_flip($excepts));
  437. }
  438. }