* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0 */ namespace App\Helpers; class Bbcode { private array $parsers = [ 'h1' => [ 'openBbcode' => '/^\[h1\]/i', 'closeBbcode' => '[/h1]', 'openHtml' => '

', 'closeHtml' => '

', ], 'h2' => [ 'openBbcode' => '/^\[h2\]/i', 'closeBbcode' => '[/h2]', 'openHtml' => '

', 'closeHtml' => '

', ], 'h3' => [ 'openBbcode' => '/^\[h3\]/i', 'closeBbcode' => '[/h3]', 'openHtml' => '

', 'closeHtml' => '

', ], 'h4' => [ 'openBbcode' => '/^\[h4\]/i', 'closeBbcode' => '[/h4]', 'openHtml' => '

', 'closeHtml' => '

', ], 'h5' => [ 'openBbcode' => '/^\[h5\]/i', 'closeBbcode' => '[/h5]', 'openHtml' => '
', 'closeHtml' => '
', ], 'h6' => [ 'openBbcode' => '/^\[h6\]/i', 'closeBbcode' => '[/h6]', 'openHtml' => '
', 'closeHtml' => '
', ], 'bold' => [ 'openBbcode' => '/^\[b\]/i', 'closeBbcode' => '[/b]', 'openHtml' => '', 'closeHtml' => '', ], 'italic' => [ 'openBbcode' => '/^\[i\]/i', 'closeBbcode' => '[/i]', 'openHtml' => '', 'closeHtml' => '', ], 'underline' => [ 'openBbcode' => '/^\[u\]/i', 'closeBbcode' => '[/u]', 'openHtml' => '', 'closeHtml' => '', ], 'linethrough' => [ 'openBbcode' => '/^\[s\]/i', 'closeBbcode' => '[/s]', 'openHtml' => '', 'closeHtml' => '', ], 'size' => [ 'openBbcode' => '/^\[size=(\d+)\]/i', 'closeBbcode' => '[/size]', 'openHtml' => '', 'closeHtml' => '', ], 'font' => [ 'openBbcode' => '/^\[font=([a-z0-9 ]+)\]/i', 'closeBbcode' => '[/font]', 'openHtml' => '', 'closeHtml' => '', ], 'color' => [ 'openBbcode' => '/^\[color=(\#[a-f0-9]{3,4}|\#[a-f0-9]{6}|\#[a-f0-9]{8}|[a-z]+)\]/i', 'closeBbcode' => '[/color]', 'openHtml' => '', 'closeHtml' => '', ], 'center' => [ 'openBbcode' => '/^\[center\]/i', 'closeBbcode' => '[/center]', 'openHtml' => '
', 'closeHtml' => '
', ], 'left' => [ 'openBbcode' => '/^\[left\]/i', 'closeBbcode' => '[/left]', 'openHtml' => '
', 'closeHtml' => '
', ], 'right' => [ 'openBbcode' => '/^\[right\]/i', 'closeBbcode' => '[/right]', 'openHtml' => '
', 'closeHtml' => '
', ], 'quote' => [ 'openBbcode' => '/^\[quote\]/i', 'closeBbcode' => '[/quote]', 'openHtml' => '
', 'closeHtml' => '
', ], 'namedquote' => [ 'openBbcode' => '/^\[quote=([^<>"]*?)\]/i', 'closeBbcode' => '[/quote]', 'openHtml' => '
Quoting $1:

', 'closeHtml' => '

', ], 'namedlink' => [ 'openBbcode' => '/^\[url=(.*?)\]/i', 'closeBbcode' => '[/url]', 'openHtml' => '', 'closeHtml' => '', ], 'orderedlistnumerical' => [ 'openBbcode' => '/^\[list=1\]/i', 'closeBbcode' => '[/list]', 'openHtml' => '
    ', 'closeHtml' => '
', ], 'orderedlistalpha' => [ 'openBbcode' => '/^\[list=a\]/i', 'closeBbcode' => '[/list]', 'openHtml' => '
    ', 'closeHtml' => '
', ], 'unorderedlist' => [ 'openBbcode' => '/^\[list\]/i', 'closeBbcode' => '[/list]', 'openHtml' => '', ], 'code' => [ 'openBbcode' => '/^\[code\]/i', 'closeBbcode' => '[/code]', 'openHtml' => '
',
            'closeHtml'   => '
', ], 'alert' => [ 'openBbcode' => '/^\[alert\]/i', 'closeBbcode' => '[/alert]', 'openHtml' => '
', 'closeHtml' => '
', ], 'note' => [ 'openBbcode' => '/^\[note\]/i', 'closeBbcode' => '[/note]', 'openHtml' => '
', 'closeHtml' => '
', ], 'sub' => [ 'openBbcode' => '/^\[sub\]/i', 'closeBbcode' => '[/sub]', 'openHtml' => '', 'closeHtml' => '', ], 'sup' => [ 'openBbcode' => '/^\[sup\]/i', 'closeBbcode' => '[/sup]', 'openHtml' => '', 'closeHtml' => '', ], 'small' => [ 'openBbcode' => '/^\[small\]/i', 'closeBbcode' => '[/small]', 'openHtml' => '', 'closeHtml' => '', ], 'table' => [ 'openBbcode' => '/^\[table\]/i', 'closeBbcode' => '[/table]', 'openHtml' => '', 'closeHtml' => '
', ], 'table-row' => [ 'openBbcode' => '/^\[tr\]/i', 'closeBbcode' => '[/tr]', 'openHtml' => '', 'closeHtml' => '', ], 'table-data' => [ 'openBbcode' => '/^\[td\]/i', 'closeBbcode' => '[/td]', 'openHtml' => '', 'closeHtml' => '', ], 'spoiler' => [ 'openBbcode' => '/^\[spoiler\]/i', 'closeBbcode' => '[/spoiler]', 'openHtml' => '
Spoiler
', 'closeHtml' => '
', ], 'named-spoiler' => [ 'openBbcode' => '/^\[spoiler=(.*?)\]/i', 'closeBbcode' => '[/spoiler]', 'openHtml' => '
$1
', 'closeHtml' => '
', ], ]; /** * Parses the BBCode string. */ public function parse($source, $replaceLineBreaks = true): string { // Replace all void elements since they don't have closing tags $source = str_replace('[*]', '
  • ', $source); $source = preg_replace_callback( '/\[url\](.*?)\[\/url\]/i', fn ($matches) => ''.htmlspecialchars($matches[1]).'', $source ); $source = preg_replace_callback( '/\[img\](.*?)\[\/img\]/i', fn ($matches) => '', $source ); $source = preg_replace_callback( '/\[img width=(\d+)\](.*?)\[\/img\]/i', fn ($matches) => '', $source ); $source = preg_replace_callback( '/\[img=(\d+)(?:x\d+)?\](.*?)\[\/img\]/i', fn ($matches) => '', $source ); // Youtube elements need to be replaced like this because the content inside the two tags // has to be moved into an html attribute $source = preg_replace_callback( '/\[youtube\](.*?)\[\/youtube\]/i', fn ($matches) => '', $source ); $source = preg_replace_callback( '/\[video\](.*?)\[\/video\]/i', fn ($matches) => '', $source ); $source = preg_replace_callback( '/\[video="youtube"\](.*?)\[\/video\]/i', fn ($matches) => '', $source ); // Common comparison syntax used in other torrent management systems is quite specific // so it must be done here instead $source = preg_replace_callback( '/\[comparison=(.*?)\]\s*(.*?)\s*\[\/comparison\]/is', function ($matches) { $comparates = preg_split('/\s*,\s*/', $matches[1]); $urls = preg_split('/\s*(?:,|\s)\s*/', $matches[2]); $validatedUrls = collect($urls)->filter(fn ($url) => filter_var($url, FILTER_VALIDATE_URL)); $chunkedUrls = $validatedUrls->chunk(\count($comparates)); $html = view('partials.comparison', ['comparates' => $comparates, 'urls' => $chunkedUrls])->render(); $html = preg_replace('/\s+/', ' ', $html); return $html; }, $source ); // Stack of unclosed elements $openedElements = []; // Character index $index = 0; // Don't loop more than the length of the source while ($index < \strlen($source)) { // Get the next occurrence of `[` $index = strpos($source, '[', $index); // Break if there are no more occurrences of `[` if ($index === false) { break; } // Break if `[` is the last character of the source if ($index + 1 >= \strlen($source)) { break; } // Is the potential tag opening or closing? if ($source[$index + 1] === '/' && ! empty($openedElements)) { $name = array_pop($openedElements); $el = $this->parsers[$name]; $tag = substr($source, $index, \strlen($el['closeBbcode'])); // Replace bbcode tag with html tag if found tag matches expected tag, // otherwise return the expected element's to the stack if (strcasecmp($tag, $el['closeBbcode']) === 0) { $source = substr_replace($source, $el['closeHtml'], $index, \strlen($el['closeBbcode'])); } else { $openedElements[] = $name; } } else { $remainingText = substr($source, $index); // Find match between found bbcode tag and valid elements foreach ($this->parsers as $name => $el) { // The opening bbcode tag uses the regex `^` character to make // sure only the beginning of $remainingText is matched if (preg_match($el['openBbcode'], $remainingText, $matches) === 1) { $replacement = preg_replace($el['openBbcode'], $el['openHtml'], $matches[0]); $source = substr_replace($source, $replacement, $index, \strlen($matches[0])); $openedElements[] = $name; break; } } } $index++; } while (! empty($openedElements)) { $source .= $this->parsers[array_pop($openedElements)]['closeHtml']; } if ($replaceLineBreaks) { // Replace line breaks $source = str_replace(["\r\n", "\n"], '
    ', $source); } return $source; } }