diff --git a/Sources/Actions/Moderation/WatchedUsers.php b/Sources/Actions/Moderation/WatchedUsers.php index 733c83667b..4c5a31f483 100644 --- a/Sources/Actions/Moderation/WatchedUsers.php +++ b/Sources/Actions/Moderation/WatchedUsers.php @@ -429,7 +429,7 @@ public static function list_getWatchedUserPosts(int $start, int $items_per_page, $row['body'] = Parser::transform( string: $row['body'], - input_types: Parser::INPUT_BBC | Parser::INPUT_MARKDOWN | ((bool) $row['last_smileys'] ? Parser::INPUT_SMILEYS : 0), + input_types: Parser::INPUT_BBC | Parser::INPUT_MARKDOWN | ((bool) $row['smileys_enabled'] ? Parser::INPUT_SMILEYS : 0), options: ['cache_id' => (int) $row['id_msg']], ); diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index d813585ae0..c8162b0663 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -959,7 +959,7 @@ protected function showPreview(): void // Do all bulletin board code tags, with or without smileys. Utils::$context['preview_message'] = Parser::transform( string: Utils::$context['preview_message'], - input_types: Parser::INPUT_BBC | Parser::INPUT_MARKDOWN | (isset($_REQUEST['ns']) ? Parser::INPUT_SMILEYS : 0), + input_types: Parser::INPUT_BBC | Parser::INPUT_MARKDOWN | (!isset($_REQUEST['ns']) ? Parser::INPUT_SMILEYS : 0), ); Lang::censorText(Utils::$context['preview_message']); diff --git a/Sources/Mail.php b/Sources/Mail.php index 27e31a0037..bad2f19f0a 100644 --- a/Sources/Mail.php +++ b/Sources/Mail.php @@ -118,7 +118,7 @@ public static function send( } // Use real tabs. - $message = strtr($message, [Utils::TAB_SUBSTITUTE => $send_html ? '' . "\t" . '' : "\t"]); + $message = strtr($message, [Utils::TAB_SUBSTITUTE => $send_html ? '' . "\t" . '' : "\t"]); list(, $from_name) = self::mimespecialchars(addcslashes($from !== null ? $from : Utils::$context['forum_name'], '<>()\'\\"'), true, $hotmail_fix, $line_break); list(, $subject) = self::mimespecialchars($subject, true, $hotmail_fix, $line_break); diff --git a/Sources/Msg.php b/Sources/Msg.php index 43d13c654b..78905d38e1 100644 --- a/Sources/Msg.php +++ b/Sources/Msg.php @@ -707,15 +707,13 @@ function ($a) { } // Replace code BBC with placeholders. We'll restore them at the end. - $parts = preg_split('~(\[/code\]|\[code(?:=[^\]]+)?\])~i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); + $parts = preg_split('/(\[code(?:=[^\]]+)?\](?:[^\[]|\[(?!\/code\])|(?R))*\[\/code])/i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); for ($i = 0, $n = count($parts); $i < $n; $i++) { - // It goes 0 = outside, 1 = begin tag, 2 = inside, 3 = close tag, repeat. - if ($i % 4 == 2) { - $code_tag = $parts[$i - 1] . $parts[$i] . $parts[$i + 1]; - $substitute = $parts[$i - 1] . $i . $parts[$i + 1]; - $code_tags[$substitute] = $code_tag; - $parts[$i] = $i; + if ($i % 2 == 1) { + $substitute = md5($parts[$i]); + $code_tags[$substitute] = $parts[$i]; + $parts[$i] = $substitute; } } @@ -919,16 +917,14 @@ public static function un_preparsecode(string $message): string // Any hooks want to work here? IntegrationHook::call('integrate_unpreparsecode', [&$message]); - $parts = preg_split('~(\[/code\]|\[code(?:=[^\]]+)?\])~i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); - // We're going to unparse only the stuff outside [code]... + $parts = preg_split('/(\[code(?:=[^\]]+)?\](?:[^\[]|\[(?!\/code\])|(?R))*\[\/code])/i', $message, -1, PREG_SPLIT_DELIM_CAPTURE); + for ($i = 0, $n = count($parts); $i < $n; $i++) { - // If $i is a multiple of four (0, 4, 8, ...) then it's not a code section... - if ($i % 4 == 2) { - $code_tag = $parts[$i - 1] . $parts[$i] . $parts[$i + 1]; - $substitute = $parts[$i - 1] . $i . $parts[$i + 1]; - $code_tags[$substitute] = $code_tag; - $parts[$i] = $i; + if ($i % 2 == 1) { + $substitute = md5($parts[$i]); + $code_tags[$substitute] = $parts[$i]; + $parts[$i] = $substitute; } } diff --git a/Sources/Parser.php b/Sources/Parser.php index 9021c04631..c44e38d102 100644 --- a/Sources/Parser.php +++ b/Sources/Parser.php @@ -371,22 +371,21 @@ public static function highlightPhpCode(string $code): string $oldlevel = error_reporting(0); - $buffer = str_replace(["\n", "\r"], '', @highlight_string($code, true)); + $buffer = @highlight_string($code, true); error_reporting($oldlevel); - $buffer = preg_replace_callback_array( + return preg_replace_callback_array( [ - '~(?:' . Utils::TAB_SUBSTITUTE . ')+~u' => fn ($matches) => '' . strtr($matches[0], [Utils::TAB_SUBSTITUTE => "\t"]) . '', - '~(\h*)~' => fn ($matches) => $matches[1], + '~(?:' . Utils::TAB_SUBSTITUTE . ')+~u' => fn ($matches) => '' . strtr($matches[0], [Utils::TAB_SUBSTITUTE => "\t"]) . '', + '~(\h*)~' => fn ($matches) => $matches[1], + '~\R~' => fn ($matches) => '
', + '/\'/' => fn ($matches) => ''', + // PHP 8.3 changed the returned HTML. + '/^(
)?]*>|<\/code>(<\/pre>)?$/' => fn ($matches) => '',
 			],
 			$buffer,
 		);
-
-		// PHP 8.3 changed the returned HTML.
-		$buffer = preg_replace('/^(
)?]*>|<\/code>(<\/pre>)?$/', '', $buffer);
-
-		return strtr($buffer, ['\'' => ''']);
 	}
 
 	/**
@@ -526,6 +525,7 @@ protected function setDisabled(): void
 			$this->disabled['iurl'] = true;
 			$this->disabled['email'] = true;
 			$this->disabled['flash'] = true;
+			$this->disabled['youtube'] = true;
 
 			// @todo Change maybe?
 			if (!isset($_GET['images'])) {
@@ -628,10 +628,13 @@ protected static function toHTML(string $string, int $input_types, array $option
 
 		// Parse the BBCode.
 		if ($input_types & self::INPUT_BBC) {
-			$string = BBcodeParser::load(!empty($options['for_print']))->parse($string, $options['cache_id'], $options['parse_tags']);
+			$string = BBcodeParser::load(!empty($options['for_print']))->parse($string, !empty($input_types & self::INPUT_SMILEYS), $options['cache_id'], $options['parse_tags']);
+
+			// BBCodeParser calls the SmileyParser internally; don't repeat.
+			$input_types &= ~self::INPUT_SMILEYS;
 		}
 
-		// Parse the smileys.
+		// Parse the smileys, if we haven't already.
 		if ($input_types & self::INPUT_SMILEYS) {
 			$string = SmileyParser::load()->parse($string);
 		}
diff --git a/Sources/Parsers/BBCodeParser.php b/Sources/Parsers/BBCodeParser.php
index e8136c8d77..1504001db2 100644
--- a/Sources/Parsers/BBCodeParser.php
+++ b/Sources/Parsers/BBCodeParser.php
@@ -44,6 +44,13 @@ class BBCodeParser extends Parser
 	 */
 	protected ?string $alltags_regex = null;
 
+	/**
+	 * @var bool
+	 *
+	 * Whether smileys should be parsed while we are parsing BBCode.
+	 */
+	protected bool $smileys = true;
+
 	/**
 	 * @var array
 	 *
@@ -824,6 +831,7 @@ public function __construct(bool $for_print = false)
 	 * Parse bulletin board code in a string.
 	 *
 	 * @param string|bool $message The string to parse.
+	 * @param bool $smileys Whether to parse smileys. Default: true.
 	 * @param string|int $cache_id The cache ID.
 	 *    If $cache_id is left empty, an ID will be generated automatically.
 	 *    Manually specifying a ID is helpful in cases when an integration hook
@@ -832,7 +840,7 @@ public function __construct(bool $for_print = false)
 	 * @param array $parse_tags If set, only parses these tags rather than all of them.
 	 * @return string The parsed string.
 	 */
-	public function parse(string $message, string|int $cache_id = '', array $parse_tags = []): string
+	public function parse(string $message, bool $smileys = true, string|int $cache_id = '', array $parse_tags = []): string
 	{
 		// Don't waste cycles
 		if (strval($message) === '') {
@@ -843,6 +851,7 @@ public function parse(string $message, string|int $cache_id = '', array $parse_t
 		$this->resetRuntimeProperties();
 
 		$this->message = $message;
+		$this->smileys = $smileys;
 		$this->parse_tags = $parse_tags;
 
 		$this->setDisabled();
@@ -857,6 +866,10 @@ public function parse(string $message, string|int $cache_id = '', array $parse_t
 		}
 
 		if (!self::$enable_bbc) {
+			if ($this->smileys === true) {
+				$this->message = SmileyParser::load()->parse($this->message);
+			}
+
 			$this->message = $this->fixHtml($this->message);
 
 			return $this->message;
@@ -1863,7 +1876,7 @@ public static function codeValidate(array &$tag, array|string &$data, array $dis
 			// Fix the PHP code stuff...
 			$code = str_replace("
\t
", "\t", implode('', $php_parts)); - $code = str_replace("\t", "\t", $code); + $code = str_replace("\t", "\t", $code); if ($add_begin) { $code = preg_replace(['/^(.+?)<\?.{0,40}?php(?: |\s)/', '/\?>((?:\s*<\/(font|span)>)*)$/m'], '$1', $code, 2); @@ -2215,7 +2228,20 @@ protected function parseMessage(): void $this->message .= "\n" . $tag['after'] . "\n"; } - $this->message = strtr($this->message, ["\n" => '']); + // Parse the smileys within the parts where it can be done safely. + if ($this->smileys === true) { + $message_parts = explode("\n", $this->message); + + for ($i = 0, $n = count($message_parts); $i < $n; $i += 2) { + $message_parts[$i] = SmileyParser::load()->parse($message_parts[$i]); + } + + $this->message = implode('', $message_parts); + } + // No smileys, just get rid of the markers. + else { + $this->message = strtr($this->message, ["\n" => '']); + } // Transform the first table row into a table header and wrap the rest // in table body tags. diff --git a/Sources/Parsers/MarkdownParser.php b/Sources/Parsers/MarkdownParser.php index 4532e8d542..7b699fe1d2 100644 --- a/Sources/Parsers/MarkdownParser.php +++ b/Sources/Parsers/MarkdownParser.php @@ -191,7 +191,7 @@ class MarkdownParser extends Parser // or '|' . // Non-space, non-control characters. - '[^\s\p{Cc}]+' . + '[^\s\p{Cc}]+?' . ')' . ')'; @@ -397,7 +397,7 @@ class MarkdownParser extends Parser 'interrupts_p' => true, 'marker_pattern' => '/^((?P[*+-])|(?P\d+)(?P[.)]))\h+/u', 'opener_test' => 'testOpensListItem', - 'continue_test' => 'testContinuesListItem', + 'continue_test' => false, 'closer_test' => 'testClosesListItem', 'add' => 'addListItem', 'append' => null, @@ -1226,12 +1226,16 @@ protected function testIsIndentedCode(array $line_info): bool return true; } + if ($this->in_code === 2) { + return false; + } + if ($this->testIsBlank($line_info) && $this->in_code === 1) { return true; } if ($line_info['indent'] < 4) { - $this->in_code = $this->in_code === 1 ? 0 : $this->in_code; + $this->in_code = 0; return false; } @@ -1248,7 +1252,7 @@ protected function testIsIndentedCode(array $line_info): bool && $open_block['properties']['indent'] >= $line_info['indent'] ) ) { - $this->in_code = $this->in_code === 1 ? 0 : $this->in_code; + $this->in_code = 0; return false; } @@ -1395,21 +1399,6 @@ protected function testOpensListItem(array $line_info): bool ); } - /** - * Tests whether a line is part of a list item. - * - * @param array $line_info Info about the current line. - * @return bool Whether this line is part of a list item. - */ - protected function testContinuesListItem(array $line_info, int $last_container, int $o): bool - { - return (bool) ( - $this->open[$o]['type'] === 'list_item' - && $this->open[$o - 1]['type'] === 'list' - && $line_info['indent'] >= $this->open[$o]['properties']['indent'] - ); - } - /** * Tests whether a line closes a list item. * @@ -1827,6 +1816,21 @@ protected function addListItem(array $line_info, int $last_container, int $o): v $indent = $line_info['indent'] + mb_strlen($marker) + strspn($line_info['content'], ' ', strlen($marker)); + // Check for nested lists. + if ( + $this->open[$last_container]['type'] === 'list' + && $line_info['indent'] >= $this->open[$last_container]['properties']['indent'] + ) { + // Close the open paragraph (or whatever) inside the open list item. + while ($this->open[$o]['type'] !== 'list_item') { + $this->getMethod($this->block_types[$this->open[$o]['type']]['close'] ?? 'closeBlock')($o); + $o--; + } + + // Consider the open list item to be our container. + $last_container = $o; + } + // If this list item doesn't match the existing list's type, // exit the existing list so we can start a new one. if ( @@ -2475,8 +2479,41 @@ protected function parseInlineSecondPass(array $content): array } // We need more info to make decisions about this run of delimiter chars. - $prev_char = html_entity_decode($chars[$start - 1] ?? ' '); - $next_char = html_entity_decode($chars[$i + 1] ?? ' '); + if (isset($chars[$start - 1])) { + $prev_char = $chars[$start - 1]; + } elseif (!isset($content[$c - 1])) { + $prev_char = ' '; + } else { + $temp = $content[$c - 1]; + + while (isset($temp[array_key_last($temp)]['content'])) { + $temp = $temp[array_key_last($temp)]['content']; + } + + if (is_string(end($temp['content']))) { + $prev_char = mb_substr(end($temp['content']), -1); + } else { + $prev_char = ' '; + } + } + + if (isset($chars[$i + 1])) { + $next_char = $chars[$i + 1]; + } elseif (!isset($content[$c + 1])) { + $next_char = ' '; + } else { + $temp = $content[$c + 1]; + + while (isset($temp[0]['content'])) { + $temp = $temp[0]['content']; + } + + if (is_string(reset($temp['content']))) { + $next_char = mb_substr(reset($temp['content']), 0, 1); + } else { + $next_char = ' '; + } + } $prev_is_space = preg_match('/\s/u', $prev_char); $prev_is_punct = $prev_is_space ? false : preg_match('/\pP/u', $prev_char); @@ -2660,10 +2697,8 @@ protected function parseLink(array $chars, int &$i, array &$content): void $str = implode('', array_slice($chars, $delim['properties']['position'], $i - $delim['properties']['position'])) . ']' . mb_substr(implode('', $chars), $i + 1); - $prefix = $delim['type'] === '![' ? '!' : ''; - // Inline link/image? - if (preg_match('~^' . $prefix . self::REGEX_LINK_INLINE . '~u', $str, $matches)) { + if (preg_match('~^' . self::REGEX_LINK_INLINE . '~u', $str, $matches)) { $this->parseEmphasis($content, $c); $text = array_slice($content, $c + 1); @@ -2693,7 +2728,7 @@ protected function parseLink(array $chars, int &$i, array &$content): void self::REGEX_LINK_REF_COLLAPSED, self::REGEX_LINK_REF_SHORTCUT, ] as $regex) { - if (preg_match('~' . $prefix . $regex . '~u', $str, $matches)) { + if (preg_match('~' . $regex . '~u', $str, $matches)) { break; } } @@ -3256,6 +3291,8 @@ protected function renderBlockquote(array $element): void */ protected function renderList(array $element): void { + static $nesting_level = 0; + switch ($this->output_type) { case self::OUTPUT_BBC: if ($element['content'] === []) { @@ -3270,7 +3307,10 @@ protected function renderList(array $element): void return; } - $style_type = $element['properties']['ordered'] ? 'decimal' : 'disc'; + $ordered_styles = ['decimal', 'lower-roman', 'lower-alpha']; + $unordered_styles = ['disc', 'circle', 'square']; + + $style_type = $element['properties']['ordered'] ? $ordered_styles[$nesting_level % 3] : $unordered_styles[$nesting_level % 3]; foreach (BBCodeParser::getCodes() as $code) { if ( @@ -3297,7 +3337,9 @@ protected function renderList(array $element): void $this->rendered .= "\n"; foreach ($element['content'] as $content_element) { + $nesting_level++; $this->render($content_element); + $nesting_level--; } switch ($this->output_type) { diff --git a/Sources/ServerSideIncludes.php b/Sources/ServerSideIncludes.php index f9fd7c99dd..53dfdd03b0 100644 --- a/Sources/ServerSideIncludes.php +++ b/Sources/ServerSideIncludes.php @@ -528,7 +528,7 @@ public static function queryPosts( options: ['cache_id' => (int) $row['id_msg']], ); - $row['body'] = strtr($row['body'], [Utils::TAB_SUBSTITUTE => '' . "\t" . '']); + $row['body'] = strtr($row['body'], [Utils::TAB_SUBSTITUTE => '' . "\t" . '']); // Censor it! Lang::censorText($row['subject']); @@ -2262,7 +2262,7 @@ public static function boardNews(?int $board = null, ?int $limit = null, ?int $s options: ['cache_id' => (int) $row['id_msg']], ); - $row['body'] = strtr($row['body'], [Utils::TAB_SUBSTITUTE => '' . "\t" . '']); + $row['body'] = strtr($row['body'], [Utils::TAB_SUBSTITUTE => '' . "\t" . '']); if (!empty($recycle_board) && $row['id_board'] == $recycle_board) { $row['icon'] = 'recycled'; diff --git a/Sources/Utils.php b/Sources/Utils.php index 93cc2cc00a..0845675d60 100644 --- a/Sources/Utils.php +++ b/Sources/Utils.php @@ -228,7 +228,7 @@ class Utils * Used to force the browser not to collapse tabs. * * This will normally be replaced in the final output with a real tab - * character wrapped in a span with "white-space: pre-wrap" applied to it. + * character wrapped in a span with "white-space: pre" applied to it. * But if this substitute string somehow makes it into the final output, * it will still look like an appropriately sized string of white space. */ @@ -2354,7 +2354,7 @@ public static function obExit(?bool $header = null, ?bool $do_footer = null, boo ob_start('SMF\\QueryString::ob_sessrewrite'); // Force the browser not to collapse tabs inside posts, etc. - ob_start(fn ($buffer) => strtr($buffer, [self::TAB_SUBSTITUTE => '' . "\t" . ''])); + ob_start(fn ($buffer) => strtr($buffer, [self::TAB_SUBSTITUTE => '' . "\t" . ''])); if (!empty(Theme::$current->settings['output_buffers']) && is_string(Theme::$current->settings['output_buffers'])) { $buffers = explode(',', Theme::$current->settings['output_buffers']); diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index a24e700fdc..9386402996 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -379,7 +379,7 @@ blockquote cite::before { margin: 1px 0 6px 0; padding: 3px 12px; overflow: auto; - white-space: nowrap; + white-space: pre; max-height: 25em; } /* The "Quote:" and "Code:" header parts... */ diff --git a/Themes/default/css/jquery.sceditor.default.css b/Themes/default/css/jquery.sceditor.default.css index 57a58d917f..186ee1e576 100644 --- a/Themes/default/css/jquery.sceditor.default.css +++ b/Themes/default/css/jquery.sceditor.default.css @@ -58,7 +58,7 @@ code { position: relative; background: #eee; border: 1px solid #aaa; - white-space: pre; + white-space: pre-wrap; padding: .25em; display: block; } diff --git a/Themes/default/scripts/jquery.sceditor.smf.js b/Themes/default/scripts/jquery.sceditor.smf.js index 1e897a2fd9..befa682a1b 100644 --- a/Themes/default/scripts/jquery.sceditor.smf.js +++ b/Themes/default/scripts/jquery.sceditor.smf.js @@ -1087,7 +1087,7 @@ sceditor.formats.bbcode.set( isSelfClosing: true, isInline: true, format: '\t', - html: '\t' + html: '\t' } ); @@ -1598,7 +1598,7 @@ sceditor.formats.bbcode.set( html: function (element, attrs, content) { var from = attrs.defaultattr ? ' data-title="' + attrs.defaultattr + '"' : ''; - return '' + content.replace('[', '[') + '' + return '' + content.replace('[', '[').replaceAll(/\[tab\]/, '\t') + '' } } );