namespace Nuuru.Server.Services.BBCode { public class BbParser { private readonly List _tokens; private readonly BBCodeContext _context; private int _pos; // Tags that support content (have open/close pairs) private static readonly HashSet KnownTags = new(StringComparer.OrdinalIgnoreCase) { "b", "i", "s", "u", "code", "inline", "h1", "h2", "h3", "h4", "sub", "sup", "spoiler", "blur", "color", "size", "font", "align", "list", "*", "greentext", "redtext", "orangetext", "bluetext", "glowtext", "redglow", "yellowglow", "rainbow", "url", "thumb", "quote", "mention" }; // Tags only available in Forum context private static readonly HashSet ForumOnlyTags = new(StringComparer.OrdinalIgnoreCase) { "attachment" }; private const int MaxAttachmentDimension = 4096; public BbParser(List tokens, BBCodeContext context = BBCodeContext.Comment) { _tokens = tokens; _context = context; _pos = 0; } public List Parse() { var nodes = new List(); while (!AtEnd) { var node = ParseNode(); if (node != null) { nodes.Add(node); } } return nodes; } private bool AtEnd => _pos >= _tokens.Count; private BbToken? Current => AtEnd ? null : _tokens[_pos]; private void Advance() => _pos++; private BbNode? ParseNode(int quoteNestingLevel = 0) { if (AtEnd) return null; var token = Current!; Advance(); return token switch { TextToken t => new TextNode(t.Content), NewlineToken => new NewlineNode(), GreentextToken => ParseGreentextLine(quoteNestingLevel), OrangetextToken => ParseOrangetextLine(quoteNestingLevel), EmoteToken e => new EmoteNode(e.Name), OpenTagToken open => ParseElement(open, quoteNestingLevel), OpenTagWithAttributesToken attrTag => ParseAttributeTag(attrTag, quoteNestingLevel), CloseTagToken close => new TextNode($"[/{close.Name}]"), // Orphan close tag - render as text _ => null }; } private BbNode ParseGreentextLine(int quoteNestingLevel) { var children = new List(); while (!AtEnd) { // Stop at newline (don't consume it) if (Current is NewlineToken) break; // Stop at close tag for a parent scope (don't consume it) if (Current is CloseTagToken) break; var child = ParseNode(quoteNestingLevel); if (child != null) children.Add(child); } if (children.Count == 0) return new TextNode(">"); return new ElementNode("greentext", "line", children); } private BbNode ParseOrangetextLine(int quoteNestingLevel) { var children = new List(); while (!AtEnd) { // Stop at newline (don't consume it) if (Current is NewlineToken) break; // Stop at close tag for a parent scope (don't consume it) if (Current is CloseTagToken) break; var child = ParseNode(quoteNestingLevel); if (child != null) children.Add(child); } if (children.Count == 0) return new TextNode("<"); return new ElementNode("orangetext", "line", children); } private BbNode ParseElement(OpenTagToken open, int quoteNestingLevel = 0) { // Check for forum-only tags if (ForumOnlyTags.Contains(open.Name)) { if (_context != BBCodeContext.Forum) { // In non-forum context, render as plain text var tagText = open.Attribute != null ? $"[{open.Name}={open.Attribute}]" : $"[{open.Name}]"; return new TextNode(tagText); } // Handle attachment tag if (open.Name.Equals("attachment", StringComparison.OrdinalIgnoreCase)) { return ParseAttachment(open.Attribute); } } // Unknown tag - render as text if (!KnownTags.Contains(open.Name) && !ForumOnlyTags.Contains(open.Name)) { var tagText = open.Attribute != null ? $"[{open.Name}={open.Attribute}]" : $"[{open.Name}]"; return new TextNode(tagText); } // Special handling for [thumb] if (open.Name == "thumb") { return ParseThumb(); } // Special handling for [url] if (open.Name == "url") { return ParseUrl(open.Attribute); } // Special handling for [quote] (simple form without attributes) if (open.Name == "quote") { return ParseSimpleQuote(open.Attribute, quoteNestingLevel); } // Special handling for [*] (list item) - auto-closes at next [*] or [/list] if (open.Name == "*") { var liChildren = new List(); while (!AtEnd) { // Auto-close at next [*] (don't consume it) if (Current is OpenTagToken nextOpen && nextOpen.Name == "*") break; // Auto-close at [/list] (don't consume it) if (Current is CloseTagToken listClose && listClose.Name.Equals("list", StringComparison.OrdinalIgnoreCase)) break; // Also stop at [/*] if someone writes it if (Current is CloseTagToken starClose && starClose.Name == "*") { Advance(); break; } var child = ParseNode(quoteNestingLevel); if (child != null) liChildren.Add(child); } return new ElementNode("*", null, liChildren); } // Regular element - parse children until matching close tag var children = new List(); while (!AtEnd) { // Check for matching close tag if (Current is CloseTagToken close && close.Name.Equals(open.Name, StringComparison.OrdinalIgnoreCase)) { Advance(); // consume close tag break; } var child = ParseNode(quoteNestingLevel); if (child != null) { children.Add(child); } } return new ElementNode(open.Name, open.Attribute, children); } private BbNode ParseAttributeTag(OpenTagWithAttributesToken token, int nestingLevel) { return token.Name.ToLowerInvariant() switch { "quote" => ParseQuote(token, nestingLevel), "mention" => ParseMention(token), "attachment" when _context == BBCodeContext.Forum => ParseAttachmentWithAttributes(token), _ => new TextNode($"[{token.Name}]") // Unknown attribute tag }; } private BbNode ParseMention(OpenTagWithAttributesToken token) { // Extract userguid attribute if (!token.Attributes.TryGetValue("userguid", out var userGuidStr) || !Guid.TryParse(userGuidStr, out var userId)) { // Invalid mention - consume content and render as text var textContent = ConsumeUntilCloseTag("mention"); return new TextNode($"[mention]{textContent}[/mention]"); } // Collect username from content until [/mention] var contentBuilder = new System.Text.StringBuilder(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("mention", StringComparison.OrdinalIgnoreCase)) { Advance(); // consume close tag break; } if (Current is TextToken text) { contentBuilder.Append(text.Content); Advance(); } else { // Non-text content in mention tag - skip it Advance(); } } var userName = contentBuilder.ToString().Trim(); // Remove @ prefix if present for storage if (userName.StartsWith('@')) { userName = userName[1..]; } if (string.IsNullOrEmpty(userName)) { return new TextNode($"[mention userguid={userGuidStr}][/mention]"); } int? postId = token.Attributes.TryGetValue("postid", out var postIdStr) && int.TryParse(postIdStr, out var pid) ? pid : null; int? commentId = token.Attributes.TryGetValue("commentid", out var commentIdStr) && int.TryParse(commentIdStr, out var cid) ? cid : null; return new MentionNode(userId, userName, postId, commentId); } private BbNode ParseQuote(OpenTagWithAttributesToken token, int nestingLevel) { var children = new List(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("quote", StringComparison.OrdinalIgnoreCase)) { Advance(); // Skip first newline after closing tag if (Current is NewlineToken) { Advance(); } break; } var child = ParseNode(nestingLevel + 1); if (child != null) children.Add(child); } // Extract attributes token.Attributes.TryGetValue("postId", out var postIdRaw); token.Attributes.TryGetValue("commentId", out var commentIdRaw); token.Attributes.TryGetValue("author", out var authorEncoded); token.Attributes.TryGetValue("hash", out var hash); // URL decode the author name var author = !string.IsNullOrEmpty(authorEncoded) ? Uri.UnescapeDataString(authorEncoded) : null; var hasValidPostId = int.TryParse(postIdRaw, out var postId) && postId > 0; var hasValidCommentId = int.TryParse(commentIdRaw, out var commentId) && commentId > 0; var sourceType = hasValidPostId ? "forum" : hasValidCommentId ? "comment" : ""; var sourceId = hasValidPostId ? postId.ToString() : hasValidCommentId ? commentId.ToString() : ""; return new QuoteNode(sourceType, sourceId, author, hash, children); } private BbNode ParseSimpleQuote(string? attribute, int nestingLevel) { var children = new List(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("quote", StringComparison.OrdinalIgnoreCase)) { Advance(); // Skip first newline after closing tag if (Current is NewlineToken) { Advance(); } break; } var child = ParseNode(nestingLevel + 1); if (child != null) children.Add(child); } // Simple quote without verification - attribute could be author name return new QuoteNode("", "", attribute, null, children); } private string ConsumeUntilCloseTag(string tagName) { var content = new System.Text.StringBuilder(); var depth = 1; while (!AtEnd && depth > 0) { var token = Current!; Advance(); switch (token) { case OpenTagToken open when open.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase): case OpenTagWithAttributesToken attr when attr.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase): depth++; content.Append($"[{tagName}]"); break; case CloseTagToken close when close.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase): depth--; if (depth > 0) content.Append($"[/{tagName}]"); break; case TextToken text: content.Append(text.Content); break; case NewlineToken: content.Append('\n'); break; } } return content.ToString(); } private BbNode ParseThumb() { // Collect content until [/thumb] var contentBuilder = new System.Text.StringBuilder(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("thumb", StringComparison.OrdinalIgnoreCase)) { Advance(); // consume close tag break; } if (Current is TextToken text) { contentBuilder.Append(text.Content); Advance(); } else { // Non-text content in thumb tag - skip it Advance(); } } var content = contentBuilder.ToString().Trim(); // Try to parse as post ID if (int.TryParse(content, out var postId) && postId > 0) { return new ThumbNode(postId); } // Invalid thumb - render as text return new TextNode($"[thumb]{content}[/thumb]"); } private BbNode ParseUrl(string? attribute) { var children = new List(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("url", StringComparison.OrdinalIgnoreCase)) { Advance(); // consume close tag break; } var child = ParseNode(); if (child != null) { children.Add(child); } } // If attribute provided, use it as href if (!string.IsNullOrEmpty(attribute)) { if (IsValidUrl(attribute)) { return new UrlNode(attribute, children); } // Invalid URL - render children as plain text with the bad tag return new ElementNode("url", attribute, children); } // No attribute - extract URL from children (only if single text node) if (children.Count == 1 && children[0] is TextNode textNode) { var url = textNode.Content.Trim(); if (IsValidUrl(url)) { return new UrlNode(url, [new TextNode(url)]); } } // Can't determine URL - render as plain element return new ElementNode("url", null, children); } private static bool IsValidUrl(string url) { if (string.IsNullOrWhiteSpace(url) || url.Length > 2048) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; } private BbNode ParseAttachment(string? attribute) { // [attachment=thumb] or [attachment] var isThumbnail = attribute?.Equals("thumb", StringComparison.OrdinalIgnoreCase) == true; // Collect GUID content until [/attachment] var contentBuilder = new System.Text.StringBuilder(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("attachment", StringComparison.OrdinalIgnoreCase)) { Advance(); // consume close tag break; } if (Current is TextToken text) { contentBuilder.Append(text.Content); Advance(); } else { // Non-text content in attachment tag - skip it Advance(); } } var content = contentBuilder.ToString().Trim(); // Try to parse as GUID if (Guid.TryParse(content, out var attachmentId)) { return new AttachmentNode(attachmentId, isThumbnail, null, null); } // Invalid attachment - render as text return new TextNode($"[attachment]{content}[/attachment]"); } private BbNode ParseAttachmentWithAttributes(OpenTagWithAttributesToken token) { // [attachment width=400 height=300]guid[/attachment] int? width = null; int? height = null; if (token.Attributes.TryGetValue("width", out var widthStr) && int.TryParse(widthStr, out var w) && w > 0 && w <= MaxAttachmentDimension) { width = w; } if (token.Attributes.TryGetValue("height", out var heightStr) && int.TryParse(heightStr, out var h) && h > 0 && h <= MaxAttachmentDimension) { height = h; } // Collect GUID content until [/attachment] var contentBuilder = new System.Text.StringBuilder(); while (!AtEnd) { if (Current is CloseTagToken close && close.Name.Equals("attachment", StringComparison.OrdinalIgnoreCase)) { Advance(); break; } if (Current is TextToken text) { contentBuilder.Append(text.Content); Advance(); } else { Advance(); } } var content = contentBuilder.ToString().Trim(); if (Guid.TryParse(content, out var attachmentId)) { return new AttachmentNode(attachmentId, false, width, height); } return new TextNode($"[attachment ...]{content}[/attachment]"); } } }