|
| 1 | +/** |
| 2 | + * Steam BBCode to HTML Parser |
| 3 | + * Efficiently converts Steam's BBCode format to HTML using a rule-based system |
| 4 | + */ |
| 5 | + |
| 6 | +// Simple tag replacements (pattern -> replacement) |
| 7 | +const SIMPLE_RULES = [ |
| 8 | + // Pre-cleanup |
| 9 | + [/\\\[/g, '['], |
| 10 | + [/\\\]/g, ']'], |
| 11 | + [/\b(?:no\s+(?:image|vids?)\s+)/gi, ''], |
| 12 | + |
| 13 | + // Headers |
| 14 | + [/\[h1\]([\s\S]*?)\[\/h1\]/gi, '<h2 style="font-size: 2rem; font-weight: bold; margin: 1.5rem 0 0.75rem 0; color: #ffffff;">$1</h2>'], |
| 15 | + [/\[h2\]([\s\S]*?)\[\/h2\]/gi, '<h3 style="font-size: 1.75rem; font-weight: bold; margin: 1.5rem 0 0.75rem 0; color: #ffffff;">$1</h3>'], |
| 16 | + [/\[h3\]([\s\S]*?)\[\/h3\]/gi, '<h4 style="font-size: 1.5rem; font-weight: bold; margin: 1.5rem 0 0.75rem 0; color: #e5e7eb;">$1</h4>'], |
| 17 | + |
| 18 | + // Lists |
| 19 | + [/\[list\]/gi, '<ul style="margin: 0.75rem 0; padding-left: 2rem; list-style-type: disc;">'], |
| 20 | + [/\[\/list\]/gi, '</ul>'], |
| 21 | + [/\[olist\]/gi, '<ol style="margin: 0.75rem 0; padding-left: 2rem;">'], |
| 22 | + [/\[\/olist\]/gi, '</ol>'], |
| 23 | + [/\[\*\]/gi, '<li style="margin: 0.5rem 0; line-height: 1.6;">'], |
| 24 | + [/\[\/\*\]/gi, '</li>'], |
| 25 | + [/\[\*\/\]/gi, '</li>'], |
| 26 | + |
| 27 | + // Paragraphs |
| 28 | + [/\[p\s+[^\]]*\]/gi, '<br>'], |
| 29 | + [/\[p\]/gi, '<br>'], |
| 30 | + [/\[\/p\]/gi, ''], |
| 31 | + [/<li[^>]*>\s*<br>/gi, m => m.replace('<br>', '')], |
| 32 | + |
| 33 | + // Horizontal rules |
| 34 | + [/\[hr\]\[\/hr\]/gi, '<hr style="border: none; border-top: 1px solid #374151; margin: 1.5rem 0;" />'], |
| 35 | + [/\[hr\]/gi, '<hr style="border: none; border-top: 1px solid #374151; margin: 1.5rem 0;" />'], |
| 36 | + [/\[\/hr\]/gi, ''], |
| 37 | + |
| 38 | + // Text formatting |
| 39 | + [/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong style="font-weight: 700; color: #ffffff;">$1</strong>'], |
| 40 | + [/\[i\]([\s\S]*?)\[\/i\]/gi, '<em style="font-style: italic;">$1</em>'], |
| 41 | + [/\[u\]([\s\S]*?)\[\/u\]/gi, '<u style="text-decoration: underline; text-decoration-thickness: 1.5px;">$1</u>'], |
| 42 | + [/\[strike\]([\s\S]*?)\[\/strike\]/gi, '<s style="opacity: 0.7;">$1</s>'], |
| 43 | + [/\[c\]([\s\S]*?)\[\/c\]/gi, '<div style="text-align: center; font-style: italic; opacity: 0.8; margin: 0.5rem 0;">$1</div>'], |
| 44 | + |
| 45 | + // Links |
| 46 | + [/\[url=["']([^"']+)["']\]([\s\S]*?)\[\/url\]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$2</a>'], |
| 47 | + [/\[url=([^\]]+?)\]([\s\S]*?)\[\/url\]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$2</a>'], |
| 48 | + [/\[url\]([\s\S]*?)\[\/url\]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$1</a>'], |
| 49 | + |
| 50 | + // Dynamic links |
| 51 | + [/\[dynamiclink\s+href=["']([^"']+)["']\]([\s\S]*?)\[\/dynamiclink\]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$2</a>'], |
| 52 | + [/\[dynamiclink\s+href=([^\]]+?)\]([\s\S]*?)\[\/dynamiclink\]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$2</a>'], |
| 53 | + [/\[dynamiclink\s+href=["']([^"']+)["']\s*\/?]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$1</a>'], |
| 54 | + [/\[dynamiclink\s+href=([^\]]+?)\s*\/?]/gi, '<a href="$1" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$1</a>'], |
| 55 | + |
| 56 | + // Code blocks |
| 57 | + [/\[code\]([\s\S]*?)\[\/code\]/gi, '<code style="background: #374151; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.875rem;">$1</code>'], |
| 58 | + [/\[pre\]([\s\S]*?)\[\/pre\]/gi, '<pre style="background: #374151; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-family: monospace; font-size: 0.875rem;">$1</pre>'], |
| 59 | + |
| 60 | + // Steam placeholders |
| 61 | + [/\{STEAM_CLAN_IMAGE\}\/(\d+)\/([a-f0-9]+\.(png|jpg|jpeg|gif))/gi, (m, appid, img) => `https://clan.cloudflare.steamstatic.com/images/${appid}/${img}`], |
| 62 | + |
| 63 | + // Line breaks |
| 64 | + [/\n\n+/g, '<br><br>'], |
| 65 | + [/\n/g, '<br>'] |
| 66 | +]; |
| 67 | + |
| 68 | +// Cleanup rules (run at the end) |
| 69 | +const CLEANUP_RULES = [ |
| 70 | + [/\[img\s+src=["']/gi, ''], |
| 71 | + [/\[carousel\]/gi, ''], |
| 72 | + [/\[\/carousel\]/gi, ''], |
| 73 | + [/\[video[^\]]*\]/gi, ''], |
| 74 | + [/\[\/video\]/gi, ''], |
| 75 | + [/\[["']/g, ''], |
| 76 | + [/["']\s*\]/g, ''], |
| 77 | + [/['"]\s*$/gm, ''], |
| 78 | + [/\[\]/g, ''], |
| 79 | + [/\]\s*$/gm, ''], |
| 80 | + [/\[\/[^\]]*\]/gi, ''], |
| 81 | + [/\s{3,}/g, ' '], |
| 82 | + [/(<br>\s*){3,}/g, '<br><br>'] |
| 83 | +]; |
| 84 | + |
| 85 | +// Complex handlers that need custom logic |
| 86 | +function handleYouTubeEmbeds(text) { |
| 87 | + return text |
| 88 | + .replace(/\[previewyoutube="([^"]+)";[^\]]*\][\s\S]*?\[\/previewyoutube\]/gi, (m, id) => |
| 89 | + `<div class="youtube-embed" style="margin: 1.5rem 0; position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; border-radius: 0.5rem; background: #1a1a1a;"><iframe src="https://www.youtube.com/embed/${id}?autoplay=0&rel=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none;" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe></div>`) |
| 90 | + .replace(/\[previewyoutube=([^;\]]+);[^\]]*\]/gi, (m, id) => |
| 91 | + `<div class="youtube-embed" style="margin: 1.5rem 0; position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; border-radius: 0.5rem; background: #1a1a1a;"><iframe src="https://www.youtube.com/embed/${id}?autoplay=0&rel=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none;" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe></div>`); |
| 92 | +} |
| 93 | + |
| 94 | +function handleVideoEmbeds(text) { |
| 95 | + return text.replace(/\[video\s+([^\]]+?)\][\s\S]*?\[\/video\]/gi, (match, attrs) => { |
| 96 | + const extract = (pattern) => (attrs.match(pattern) || [])[1] || ''; |
| 97 | + const webm = extract(/webm=["']([^"']+)["']/i); |
| 98 | + const mp4 = extract(/mp4=["']([^"']+)["']/i); |
| 99 | + const poster = extract(/poster=["']([^"']+)["']/i); |
| 100 | + const autoplay = /autoplay=["']?true["']?/i.test(attrs); |
| 101 | + const controls = !/controls=["']?false["']?/i.test(attrs); |
| 102 | + |
| 103 | + let html = '<video preload="metadata" style="max-width: 100%; height: auto; border-radius: 8px; margin: 1rem 0; background: #000;"'; |
| 104 | + if (poster) html += ` poster="${poster}"`; |
| 105 | + if (autoplay) html += ' autoplay muted loop playsinline'; |
| 106 | + if (controls) html += ' controls'; |
| 107 | + html += '>'; |
| 108 | + if (mp4) html += `<source src="${mp4}" type="video/mp4">`; |
| 109 | + if (webm) html += `<source src="${webm}" type="video/webm">`; |
| 110 | + html += 'Your browser does not support the video tag.</video>'; |
| 111 | + return html; |
| 112 | + }); |
| 113 | +} |
| 114 | + |
| 115 | +function handleImages(text) { |
| 116 | + return text |
| 117 | + .replace(/\[img\]([\s\S]*?)(?:\[\/img\]|(?=\[)|$)/gi, (m, content) => { |
| 118 | + const url = (content.match(/(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/i) || [])[1]; |
| 119 | + return url ? `<img src="${url}" alt="Steam content" style="max-width: 100%; height: auto; border-radius: 8px; margin: 1rem 0; display: block;" />` : ''; |
| 120 | + }) |
| 121 | + .replace(/\[img\s+src=["']([^"']+)["']\s*\/?]/gi, '<img src="$1" alt="Steam content" style="max-width: 100%; height: auto; border-radius: 8px; margin: 1rem 0; display: block;" />') |
| 122 | + .replace(/\[img=([^\]]+)\]/gi, '<img src="$1" alt="Steam content" style="max-width: 100%; height: auto; border-radius: 8px; margin: 1rem 0; display: block;" />') |
| 123 | + .replace(/\[img[\s\S]*?\]/gi, '') |
| 124 | + .replace(/\[\/img\]/gi, ''); |
| 125 | +} |
| 126 | + |
| 127 | +/** |
| 128 | + * Main parser function |
| 129 | + * @param {string} bbcode - Steam BBCode text |
| 130 | + * @returns {string} - HTML output |
| 131 | + */ |
| 132 | +function parseSteamBBCode(bbcode) { |
| 133 | + let result = bbcode; |
| 134 | + |
| 135 | + // Apply simple rules |
| 136 | + SIMPLE_RULES.forEach(([pattern, replacement]) => { |
| 137 | + result = result.replace(pattern, replacement); |
| 138 | + }); |
| 139 | + |
| 140 | + // Apply complex handlers |
| 141 | + result = handleYouTubeEmbeds(result); |
| 142 | + result = handleVideoEmbeds(result); |
| 143 | + result = handleImages(result); |
| 144 | + |
| 145 | + // Final cleanup |
| 146 | + CLEANUP_RULES.forEach(([pattern, replacement]) => { |
| 147 | + result = result.replace(pattern, replacement); |
| 148 | + }); |
| 149 | + |
| 150 | + return result; |
| 151 | +} |
| 152 | + |
| 153 | +module.exports = parseSteamBBCode; |
| 154 | +module.exports.parseSteamBBCode = parseSteamBBCode; |
| 155 | +module.exports.default = parseSteamBBCode; |
0 commit comments